-
Notifications
You must be signed in to change notification settings - Fork 0
feat: team + operator + seed + health endpoints #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3b7ca95
103638a
54e379a
1721159
6e0baac
639393b
c938357
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,9 @@ | |
| # Cache JWKS so we don't fetch on every request | ||
| _jwks_cache: dict | None = None | ||
|
|
||
| # Operator allowlist — god-mode admin access | ||
| OPERATOR_DOMAINS = {"gradata.ai", "sprites.ai"} | ||
|
|
||
|
|
||
| async def _get_jwks() -> dict: | ||
| """Fetch and cache the Supabase JWKS for ES256 verification.""" | ||
|
|
@@ -47,8 +50,8 @@ async def verify_api_key(key: str) -> dict: | |
| return rows[0] | ||
|
|
||
|
|
||
| async def verify_jwt(signed_jwt: str) -> str: | ||
| """Verify a Supabase JWT (ES256 or HS256) and return the user_id.""" | ||
| async def verify_jwt_claims(signed_jwt: str) -> dict: | ||
| """Verify a Supabase JWT (ES256 or HS256) and return the full claims dict.""" | ||
| settings = get_settings() | ||
|
|
||
| # Try ES256 with JWKS first (modern Supabase projects) | ||
|
|
@@ -67,10 +70,9 @@ async def verify_jwt(signed_jwt: str) -> str: | |
| algorithms=["ES256"], | ||
| options={"verify_aud": False}, | ||
| ) | ||
| user_id = claims.get("sub") | ||
| if not user_id: | ||
| if not claims.get("sub"): | ||
| raise HTTPException(status_code=401, detail="Invalid JWT: no sub claim") | ||
| return user_id | ||
| return claims | ||
| except JWTError: | ||
| pass # Fall through to HS256 | ||
|
|
||
|
|
@@ -82,14 +84,19 @@ async def verify_jwt(signed_jwt: str) -> str: | |
| algorithms=["HS256"], | ||
| options={"verify_aud": False}, | ||
| ) | ||
| user_id = claims.get("sub") | ||
| if not user_id: | ||
| if not claims.get("sub"): | ||
| raise HTTPException(status_code=401, detail="Invalid JWT: no sub claim") | ||
| return user_id | ||
| return claims | ||
| except JWTError as e: | ||
| raise HTTPException(status_code=401, detail=f"Invalid JWT: {e}") from e | ||
|
|
||
|
|
||
| async def verify_jwt(signed_jwt: str) -> str: | ||
| """Verify a Supabase JWT and return just the user_id.""" | ||
| claims = await verify_jwt_claims(signed_jwt) | ||
| return claims["sub"] | ||
|
|
||
|
|
||
| async def get_current_brain( | ||
| credentials: HTTPAuthorizationCredentials = Security(_bearer), | ||
| ) -> dict: | ||
|
|
@@ -144,3 +151,45 @@ async def get_brain_for_request( | |
|
|
||
| user_id = await verify_jwt(cred) | ||
| return await verify_brain_ownership(brain_id, user_id) | ||
|
|
||
|
|
||
| async def _resolve_user_email(user_id: str, claims: dict) -> str | None: | ||
| """Resolve the caller's email — prefer JWT claim, fall back to auth.users lookup.""" | ||
| email = claims.get("email") | ||
| if email: | ||
| return email | ||
|
|
||
| # Fallback: query auth.users via the Supabase service-role client. | ||
| # Supabase exposes auth users through the admin REST endpoint, not PostgREST. | ||
| # If your db wrapper doesn't expose that, the JWT claim path is the primary route. | ||
| try: | ||
| db = get_db() | ||
| rows = await db.select("users", columns="email", filters={"id": user_id}) | ||
| if rows: | ||
| return rows[0].get("email") | ||
| except Exception as exc: # pragma: no cover - defensive | ||
| _log.warning("Failed to resolve email for user=%s: %s", user_id, exc) | ||
| return None | ||
|
Comment on lines
+156
to
+172
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "Searching for users-table definitions and auth.users access..."
rg -n -C2 'CREATE TABLE .*users|auth\.users|select\("users"|from\("users"' cloud
echo
echo "Inspecting the DB wrapper select() API..."
rg -n -C3 'async def select\(|def select\(' cloud/appRepository: Gradata/gradata Length of output: 3541 🏁 Script executed: # Find the DB wrapper implementation and get_db() function
rg -n 'def get_db|class.*DB|async def select' cloud/app --type py -B2 -A5Repository: Gradata/gradata Length of output: 1027 🏁 Script executed: # Check if there's a public "users" table definition in migrations
rg -n 'CREATE TABLE.*users|CREATE TABLE "users"' cloud/migrationsRepository: Gradata/gradata Length of output: 167 🏁 Script executed: # Look for service-role or admin API configuration
rg -n 'service.role|admin_api|supabase.*key' cloud --type pyRepository: Gradata/gradata Length of output: 1736 🏁 Script executed: # Read the complete select() method implementation
sed -n '32,80p' cloud/app/db.pyRepository: Gradata/gradata Length of output: 2104 🏁 Script executed: # Check if SupabaseClient is external or custom, and how it's imported
head -n 30 cloud/app/db.pyRepository: Gradata/gradata Length of output: 901 🏁 Script executed: # Look for any special "users" table handling or mapping in the codebase
rg -n 'users.*table|table.*users|auth\.users' cloud/app --type pyRepository: Gradata/gradata Length of output: 485 Fix the email fallback: The code calls Either:
🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| async def require_operator( | ||
| credentials: HTTPAuthorizationCredentials = Security(_bearer), | ||
| ) -> str: | ||
| """Require the caller's email domain to be in OPERATOR_DOMAINS. | ||
|
|
||
| Resolves email from the JWT's ``email`` claim when present; otherwise falls | ||
| back to a ``users`` table lookup by ``user_id``. Raises 403 otherwise. | ||
| Returns the user_id for downstream use. | ||
| """ | ||
| claims = await verify_jwt_claims(credentials.credentials) | ||
| user_id = claims["sub"] | ||
|
|
||
| email = await _resolve_user_email(user_id, claims) | ||
| if not email: | ||
| raise HTTPException(status_code=403, detail="Operator access requires a verified email") | ||
|
|
||
| domain = email.split("@", 1)[1].lower() if "@" in email else "" | ||
| if domain not in OPERATOR_DOMAINS: | ||
| raise HTTPException(status_code=403, detail="Operator access denied") | ||
|
|
||
| return user_id | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -66,6 +66,21 @@ async def update( | |
| resp.raise_for_status() | ||
| return resp.json() | ||
|
|
||
| async def delete( | ||
| self, table: str, filters: dict[str, Any] | None = None, | ||
| ) -> list[dict]: | ||
| """DELETE rows matching eq filters. Returns deleted rows when PostgREST sends them back.""" | ||
| params: dict[str, str] = {} | ||
| if filters: | ||
| for key, val in filters.items(): | ||
| params[key] = f"eq.{val}" | ||
| resp = await self._http.delete(f"/{table}", params=params) | ||
| resp.raise_for_status() | ||
| try: | ||
| return resp.json() | ||
| except ValueError: | ||
| return [] | ||
|
Comment on lines
+69
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In PostgREST (and thus Supabase REST API), sending DELETE /rest/v1/<table> without any filter query parameters generates a SQL DELETE statement with no WHERE clause, which deletes all rows in the table that the authenticated database role has DELETE permission on. This behavior follows PostgreSQL semantics: without a WHERE clause, DELETE removes all visible rows. If Row Level Security (RLS) is enabled, only rows passing the relevant DELETE policies are deleted (silently; no error if zero rows affected). The response is typically HTTP 204 No Content. Official docs warn: "Beware of accidentally deleting all rows in a table." (PostgREST v14 docs). To prevent this, use the pg-safeupdate extension (blocks WHERE-less DELETE/UPDATE), database permissions/RLS, or a reverse proxy like Nginx. Supabase docs emphasize: "Always include a filter when deleting to avoid deleting all rows in the table." Community discussions confirm users must add filters (e.g., ?id=eq.0 or ?limit=0 workaround discouraged). No built-in block in recent PostgREST v14.x (2026); a prior "limited delete" feature was removed. Citations:
Reject unfiltered deletes on the service-role client. If a caller omits 🛡️ Safer API shape async def delete(
- self, table: str, filters: dict[str, Any] | None = None,
+ self,
+ table: str,
+ filters: dict[str, Any] | None = None,
+ *,
+ allow_all: bool = False,
) -> list[dict]:
"""DELETE rows matching eq filters. Returns deleted rows when PostgREST sends them back."""
+ if not filters and not allow_all:
+ raise ValueError("Refusing unfiltered delete on service-role client")
params: dict[str, str] = {}
if filters:
for key, val in filters.items():
params[key] = f"eq.{val}"
resp = await self._http.delete(f"/{table}", params=params)🤖 Prompt for AI Agents |
||
|
|
||
| async def close(self): | ||
| await self._http.aclose() | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -141,3 +141,69 @@ async def delete_brain( | |||||||||||||||||||||||||||||||||||||||
| now = datetime.now(timezone.utc).isoformat() | ||||||||||||||||||||||||||||||||||||||||
| await db.update("brains", data={"deleted_at": now}, filters={"id": brain_id}) | ||||||||||||||||||||||||||||||||||||||||
| _log.info("Soft-deleted brain=%s", brain_id) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| class ClearDemoResponse(BaseModel): | ||||||||||||||||||||||||||||||||||||||||
| deleted: int | ||||||||||||||||||||||||||||||||||||||||
| by_table: dict[str, int] | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| @router.post("/brains/{brain_id}/clear-demo", response_model=ClearDemoResponse) | ||||||||||||||||||||||||||||||||||||||||
| async def clear_demo( | ||||||||||||||||||||||||||||||||||||||||
| brain_id: str, | ||||||||||||||||||||||||||||||||||||||||
| request: Request, | ||||||||||||||||||||||||||||||||||||||||
| credentials: HTTPAuthorizationCredentials = Depends(_bearer), | ||||||||||||||||||||||||||||||||||||||||
| ) -> ClearDemoResponse: | ||||||||||||||||||||||||||||||||||||||||
| """Delete all demo rows (is_demo=true) scoped to this brain. | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| Auth: caller must own the brain. Returns per-table delete counts and a total. | ||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||
| await get_brain_for_request(brain_id, credentials) | ||||||||||||||||||||||||||||||||||||||||
| db = get_db() | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| by_table: dict[str, int] = {} | ||||||||||||||||||||||||||||||||||||||||
| total = 0 | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| # Children first (FK order doesn't strictly matter here, but we delete | ||||||||||||||||||||||||||||||||||||||||
| # narrowest-to-widest so counts are readable). | ||||||||||||||||||||||||||||||||||||||||
| for table in ("corrections", "lessons", "meta_rules", "events"): | ||||||||||||||||||||||||||||||||||||||||
| deleted = await _delete_demo_rows(db, table, brain_id) | ||||||||||||||||||||||||||||||||||||||||
| by_table[table] = deleted | ||||||||||||||||||||||||||||||||||||||||
| total += deleted | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| # Finally the brain itself — only if it was flagged is_demo in metadata. | ||||||||||||||||||||||||||||||||||||||||
| brain_rows = await db.select( | ||||||||||||||||||||||||||||||||||||||||
| "brains", columns="id,metadata", filters={"id": brain_id} | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
| if brain_rows and _is_demo_metadata(brain_rows[0].get("metadata")): | ||||||||||||||||||||||||||||||||||||||||
| await db.delete("brains", filters={"id": brain_id}) | ||||||||||||||||||||||||||||||||||||||||
| by_table["brains"] = 1 | ||||||||||||||||||||||||||||||||||||||||
| total += 1 | ||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||
| by_table["brains"] = 0 | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+174
to
+183
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't delete the parent brain in a demo-only cleanup. Line 179 drops the 🛡️ Safer direction- if brain_rows and _is_demo_metadata(brain_rows[0].get("metadata")):
- await db.delete("brains", filters={"id": brain_id})
- by_table["brains"] = 1
- total += 1
- else:
- by_table["brains"] = 0
+ by_table["brains"] = 0
+ if brain_rows and isinstance(brain_rows[0].get("metadata"), dict):
+ metadata = dict(brain_rows[0]["metadata"])
+ metadata.pop("is_demo", None)
+ await db.update("brains", data={"metadata": metadata}, filters={"id": brain_id})If preserving the brain is not the intended UX, only delete it after proving there are no non-demo child rows left. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| _log.info("Cleared demo data for brain=%s (deleted=%d)", brain_id, total) | ||||||||||||||||||||||||||||||||||||||||
| return ClearDemoResponse(deleted=total, by_table=by_table) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| async def _delete_demo_rows(db, table: str, brain_id: str) -> int: | ||||||||||||||||||||||||||||||||||||||||
| """Fetch rows for the brain, filter by is_demo marker, delete those ids.""" | ||||||||||||||||||||||||||||||||||||||||
| rows = await db.select(table, columns="id,data", filters={"brain_id": brain_id}) | ||||||||||||||||||||||||||||||||||||||||
| demo_rows = [r for r in rows if _is_demo_data(r.get("data"))] | ||||||||||||||||||||||||||||||||||||||||
| if not demo_rows: | ||||||||||||||||||||||||||||||||||||||||
| return 0 | ||||||||||||||||||||||||||||||||||||||||
| for row in demo_rows: | ||||||||||||||||||||||||||||||||||||||||
| await db.delete(table, filters={"id": row["id"]}) | ||||||||||||||||||||||||||||||||||||||||
| return len(demo_rows) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| def _is_demo_data(data) -> bool: | ||||||||||||||||||||||||||||||||||||||||
| if not data: | ||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||
| if isinstance(data, dict): | ||||||||||||||||||||||||||||||||||||||||
| return bool(data.get("is_demo")) | ||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| def _is_demo_metadata(metadata) -> bool: | ||||||||||||||||||||||||||||||||||||||||
| return _is_demo_data(metadata) | ||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a language to this fenced block.
This is already tripping markdownlint (MD040), so the runbook will keep the docs check noisy until the fence is annotated.
📝 Minimal fix
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)
[warning] 126-126: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents