RandomCatLover Claude Sonnet 5 commited on
Commit
1b35f8e
·
1 Parent(s): 5d4959a

Switch Sheets persistence to per-user OAuth instead of a service account

Browse files

Service accounts have zero Drive storage quota, so they can never
create a new file (even in a folder shared with them) -- confirmed by
testing the raw Drive API directly. Instead, request the drive.file
scope during Google login and expose the resulting access token via
Streamlit's expose_tokens=["access"], so each user's own account
creates and owns their spreadsheet directly. Drops the service
account, shared folder, and index-spreadsheet machinery entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

README.md CHANGED
@@ -23,21 +23,30 @@ forums](https://discuss.streamlit.io).
23
 
24
  The app works fully anonymously with no setup. Logging in with Google (button
25
  in the sidebar) additionally persists your players and round history to a
26
- Google Sheet owned by a service account.
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  ### One-time Google Cloud setup
29
- 1. Enable the **Google Sheets API** and **Google Drive API** on a GCP project.
30
- 2. Create an OAuth 2.0 **Web application** Client ID for login; add your
31
- app's URL(s) as authorized redirect URIs (e.g. `http://localhost:8501` for
32
- local dev).
33
- 3. Create a **Service Account**, download its JSON key. Service accounts have
34
- no Drive storage quota of their own, so:
35
- - Create a folder in a real Google Drive account, share it with the
36
- service account's `client_email` as **Editor**, and copy the folder ID
37
- from its URL. This becomes `shared_folder_id` below.
38
- 4. Run the one-time index-spreadsheet creation (see `src/sheets_backend.py`,
39
- `get_or_create_user_workbook` needs an index spreadsheet — create one via
40
- the service account inside the shared folder and note its ID).
41
 
42
  ### Local dev — `.streamlit/secrets.toml` (gitignored, never commit this)
43
  ```toml
@@ -47,23 +56,16 @@ cookie_secret = "<random string>"
47
  client_id = "<oauth client id>"
48
  client_secret = "<oauth client secret>"
49
  server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"
 
50
 
51
- [gcp_service_account]
52
- # ...full service account JSON fields...
53
-
54
- [sheets]
55
- index_spreadsheet_id = "<id of the index spreadsheet>"
56
- shared_folder_id = "<id of the Drive folder shared with the service account>"
57
  ```
58
 
59
  ### Hugging Face Space deploy
60
  `st.login()` only reads `st.secrets`, so `src/bootstrap_secrets.py` assembles
61
  `.streamlit/secrets.toml` at startup from individual Space secrets (Settings →
62
  Variables and secrets), so nothing sensitive needs to live in the repo or the
63
- image:
64
-
65
- `AUTH_CLIENT_ID`, `AUTH_CLIENT_SECRET`, `AUTH_COOKIE_SECRET`,
66
- `AUTH_REDIRECT_URI` (your Space's public URL), `GCP_PROJECT_ID`,
67
- `GCP_PRIVATE_KEY_ID`, `GCP_PRIVATE_KEY` (paste with literal `\n` for
68
- newlines), `GCP_CLIENT_EMAIL`, `GCP_CLIENT_ID`, `GCP_CLIENT_X509_CERT_URL`,
69
- `SHEETS_INDEX_ID`, `SHEETS_SHARED_FOLDER_ID`.
 
23
 
24
  The app works fully anonymously with no setup. Logging in with Google (button
25
  in the sidebar) additionally persists your players and round history to a
26
+ Google Sheet -- created directly in **your own Drive**, owned by you.
27
+
28
+ There's no service account and no shared storage involved: Streamlit's login
29
+ requests the `drive.file` scope alongside identity, so the app gets a
30
+ short-lived OAuth token that lets it create/edit only the one spreadsheet it
31
+ creates for you (`BoardGameTracker - {your email}`), nothing else in your
32
+ Drive. Because it's created by your own account, it uses your own storage --
33
+ there's no "service account has 0 storage quota" issue to work around.
34
+
35
+ Trade-off: since the app stays in Google's "Testing" publishing status
36
+ (no Google security review needed for a small friend group), each person who
37
+ wants to log in must first be added as a **test user** in the OAuth consent
38
+ screen, and will see an "unverified app" warning the first time they
39
+ click through.
40
 
41
  ### One-time Google Cloud setup
42
+ 1. Create an OAuth 2.0 **Web application** Client ID; add your app's URL(s)
43
+ as authorized redirect URIs (e.g. `http://localhost:8501` for local dev).
44
+ 2. On the OAuth consent screen, add every player's Google account email
45
+ under **Test users** (keep publishing status as "Testing" -- this avoids
46
+ Google's verification review entirely for `drive.file`, a scope that
47
+ only grants access to files the app itself creates).
48
+ 3. No service account, no Sheets/Drive API project-level enablement, no
49
+ index spreadsheet -- the app's own OAuth client handles everything.
 
 
 
 
50
 
51
  ### Local dev — `.streamlit/secrets.toml` (gitignored, never commit this)
52
  ```toml
 
56
  client_id = "<oauth client id>"
57
  client_secret = "<oauth client secret>"
58
  server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration"
59
+ expose_tokens = ["access"]
60
 
61
+ [auth.client_kwargs]
62
+ scope = "openid email profile https://www.googleapis.com/auth/drive.file"
63
+ prompt = "consent select_account"
 
 
 
64
  ```
65
 
66
  ### Hugging Face Space deploy
67
  `st.login()` only reads `st.secrets`, so `src/bootstrap_secrets.py` assembles
68
  `.streamlit/secrets.toml` at startup from individual Space secrets (Settings →
69
  Variables and secrets), so nothing sensitive needs to live in the repo or the
70
+ image: `AUTH_CLIENT_ID`, `AUTH_CLIENT_SECRET`, `AUTH_COOKIE_SECRET`,
71
+ `AUTH_REDIRECT_URI` (your Space's public URL).
 
 
 
 
 
pages/points_tracker.py CHANGED
@@ -26,8 +26,13 @@ def _get_workbook():
26
  """Returns the logged-in user's Sheets workbook, or None for anonymous sessions."""
27
  if "auth" not in st.secrets or not st.user.is_logged_in:
28
  return None
 
 
 
29
  if "pt_workbook" not in st.session_state:
30
- st.session_state.pt_workbook = sheets.get_or_create_user_workbook(st.user.email)
 
 
31
  return st.session_state.pt_workbook
32
 
33
 
 
26
  """Returns the logged-in user's Sheets workbook, or None for anonymous sessions."""
27
  if "auth" not in st.secrets or not st.user.is_logged_in:
28
  return None
29
+ access_token = st.user.tokens.get("access")
30
+ if not access_token:
31
+ return None
32
  if "pt_workbook" not in st.session_state:
33
+ st.session_state.pt_workbook = sheets.get_or_create_user_workbook(
34
+ access_token, st.user.email
35
+ )
36
  return st.session_state.pt_workbook
37
 
38
 
src/bootstrap_secrets.py CHANGED
@@ -5,6 +5,8 @@ import tomli_w
5
 
6
  _SECRETS_PATH = Path(".streamlit/secrets.toml")
7
 
 
 
8
 
9
  def ensure_secrets_file() -> None:
10
  if _SECRETS_PATH.exists() or "AUTH_CLIENT_ID" not in os.environ:
@@ -18,23 +20,11 @@ def ensure_secrets_file() -> None:
18
  "client_id": os.environ["AUTH_CLIENT_ID"],
19
  "client_secret": os.environ["AUTH_CLIENT_SECRET"],
20
  "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
21
- },
22
- "gcp_service_account": {
23
- "type": "service_account",
24
- "project_id": os.environ["GCP_PROJECT_ID"],
25
- "private_key_id": os.environ["GCP_PRIVATE_KEY_ID"],
26
- "private_key": os.environ["GCP_PRIVATE_KEY"].replace("\\n", "\n"),
27
- "client_email": os.environ["GCP_CLIENT_EMAIL"],
28
- "client_id": os.environ["GCP_CLIENT_ID"],
29
- "auth_uri": "https://accounts.google.com/o/oauth2/auth",
30
- "token_uri": "https://oauth2.googleapis.com/token",
31
- "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
32
- "client_x509_cert_url": os.environ["GCP_CLIENT_X509_CERT_URL"],
33
- "universe_domain": "googleapis.com",
34
- },
35
- "sheets": {
36
- "index_spreadsheet_id": os.environ["SHEETS_INDEX_ID"],
37
- "shared_folder_id": os.environ.get("SHEETS_SHARED_FOLDER_ID", ""),
38
  },
39
  }
40
  _SECRETS_PATH.write_text(tomli_w.dumps(data))
 
5
 
6
  _SECRETS_PATH = Path(".streamlit/secrets.toml")
7
 
8
+ _DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file"
9
+
10
 
11
  def ensure_secrets_file() -> None:
12
  if _SECRETS_PATH.exists() or "AUTH_CLIENT_ID" not in os.environ:
 
20
  "client_id": os.environ["AUTH_CLIENT_ID"],
21
  "client_secret": os.environ["AUTH_CLIENT_SECRET"],
22
  "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
23
+ "expose_tokens": ["access"],
24
+ "client_kwargs": {
25
+ "scope": f"openid email profile {_DRIVE_FILE_SCOPE}",
26
+ "prompt": "consent select_account",
27
+ },
 
 
 
 
 
 
 
 
 
 
 
 
28
  },
29
  }
30
  _SECRETS_PATH.write_text(tomli_w.dumps(data))
src/sheets_backend.py CHANGED
@@ -2,57 +2,37 @@ import uuid
2
  from datetime import datetime, timezone
3
 
4
  import gspread
5
- import streamlit as st
6
- from google.oauth2.service_account import Credentials
7
-
8
- _SCOPES = [
9
- "https://www.googleapis.com/auth/spreadsheets",
10
- "https://www.googleapis.com/auth/drive",
11
- ]
12
 
13
  _PLAYERS_HEADER = ["player_id", "name", "created_at"]
14
  _GAMES_HEADER = ["timestamp", "game_name", "session_id", "round_number", "player_name", "score"]
 
15
 
16
 
17
- @st.cache_resource
18
- def _get_client():
19
- creds = Credentials.from_service_account_info(
20
- st.secrets["gcp_service_account"], scopes=_SCOPES
21
- )
22
  return gspread.authorize(creds)
23
 
24
 
25
- def _folder_id():
26
- return st.secrets["sheets"].get("shared_folder_id") or None
27
-
28
 
29
- def _get_index_ws():
30
- client = _get_client()
31
- sh = client.open_by_key(st.secrets["sheets"]["index_spreadsheet_id"])
32
- return sh.worksheet("users")
 
33
 
 
 
 
34
 
35
- def get_or_create_user_workbook(email: str) -> gspread.Spreadsheet:
36
- client = _get_client()
37
- index_ws = _get_index_ws()
38
- rows = index_ws.get_all_records()
39
- for row in rows:
40
- if row.get("email") == email:
41
- return client.open_by_key(row["spreadsheet_id"])
42
-
43
- sh = client.create(f"BoardGameTracker - {email}", folder_id=_folder_id())
44
  players_ws = sh.sheet1
45
  players_ws.update_title("players")
46
  players_ws.append_row(_PLAYERS_HEADER)
47
  games_ws = sh.add_worksheet(title="games_played", rows=1, cols=len(_GAMES_HEADER))
48
  games_ws.append_row(_GAMES_HEADER)
49
-
50
- try:
51
- sh.share(email, perm_type="user", role="writer")
52
- except gspread.exceptions.APIError:
53
- pass # sharing is a nice-to-have; don't block provisioning on it
54
-
55
- index_ws.append_row([email, sh.id, sh.url, datetime.now(timezone.utc).isoformat()])
56
  return sh
57
 
58
 
 
2
  from datetime import datetime, timezone
3
 
4
  import gspread
5
+ from google.oauth2.credentials import Credentials
 
 
 
 
 
 
6
 
7
  _PLAYERS_HEADER = ["player_id", "name", "created_at"]
8
  _GAMES_HEADER = ["timestamp", "game_name", "session_id", "round_number", "player_name", "score"]
9
+ _WORKBOOK_TITLE_PREFIX = "BoardGameTracker"
10
 
11
 
12
+ def _get_user_client(access_token: str) -> gspread.Client:
13
+ creds = Credentials(token=access_token)
 
 
 
14
  return gspread.authorize(creds)
15
 
16
 
17
+ def get_or_create_user_workbook(access_token: str, email: str) -> gspread.Spreadsheet:
18
+ """Returns the user's own Sheets workbook, creating it in their Drive on first use.
 
19
 
20
+ Uses the user's own OAuth access token (drive.file scope), so the file is
21
+ owned by them directly -- no service account or shared storage involved.
22
+ """
23
+ client = _get_user_client(access_token)
24
+ title = f"{_WORKBOOK_TITLE_PREFIX} - {email}"
25
 
26
+ existing = client.list_spreadsheet_files(title=title)
27
+ if existing:
28
+ return client.open_by_key(existing[0]["id"])
29
 
30
+ sh = client.create(title)
 
 
 
 
 
 
 
 
31
  players_ws = sh.sheet1
32
  players_ws.update_title("players")
33
  players_ws.append_row(_PLAYERS_HEADER)
34
  games_ws = sh.add_worksheet(title="games_played", rows=1, cols=len(_GAMES_HEADER))
35
  games_ws.append_row(_GAMES_HEADER)
 
 
 
 
 
 
 
36
  return sh
37
 
38