feat: scope grants for cross-user read/write access - #2421
standardtoaster wants to merge 6 commits into
Conversation
Add a `scope_grants` table and `ScopeGrantStore` trait that lets admins grant one user read or read-write access to another user's workspace scope. This replaces the env-var-based `GATEWAY_USER_TOKENS` mechanism removed in nearai#1626. Key changes: - New `scope_grants` table (V24 migration, both PG and libSQL) - `ScopeGrantStore` trait with list/set/revoke/list-by-scope ops - `DbAuthenticator` queries grants at auth time, populates `UserIdentity.workspace_read_scopes` and new `workspace_write_scopes` - `WorkspacePool` converts writable grants into memory layers via new `Workspace::with_additional_memory_layers()` - Admin REST API: GET/PUT/DELETE on `/api/admin/users/{id}/scope-grants` Use case: a "household" user stores shared data (grocery list, schedule). Grant Andrew read-write access to "household" and he can search/write that data from his own workspace context. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Scope owners and users with writable access can manage grants
without admin privileges. A writer on scope X can grant other
users read or read-write access to X.
Authorization: caller must be the scope owner (user_id == scope)
or hold a writable grant to the scope.
New endpoints:
GET /api/scope-grants — list my grants
GET /api/scope-grants/{scope} — list scope members
PUT /api/scope-grants/{scope}/{id} — grant access
DELETE /api/scope-grants/{scope}/{id} — revoke access
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address review feedback: - Add targeted `has_writable_grant(user_id, scope)` query to avoid scanning all grants when checking authorization - Validate grantee exists before creating a grant (404 if not found) - Only scope owners can grant writable access; writers can only grant read-only (prevents transitive privilege escalation) - Deduplicate memory layers in `with_additional_memory_layers()` when config-driven and DB-driven layers overlap Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a cross-user scope access control system, allowing users to grant read or read-write access to their workspace data to others. It includes database migrations for Postgres and LibSql, new API handlers for admin and self-service grant management, and updates to the authentication and workspace logic to enforce these grants. Feedback focuses on ensuring cache consistency by invalidating user identities and workspaces after grant changes, adding missing foreign key constraints in migrations, and improving input validation for admin endpoints.
| store | ||
| .set_scope_grant(&user_id, &scope, writable, Some(&admin.user_id)) | ||
| .await | ||
| .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
There was a problem hiding this comment.
When a scope grant is updated, the changes won't take effect immediately due to caching in both the DbAuthenticator and the WorkspacePool. To prevent divergence between the cache and the persistent store, you should invalidate the cache for the target user_id immediately after the database update succeeds.
store
.set_scope_grant(&user_id, &scope, writable, Some(&admin.user_id))
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&user_id).await;
}
if let Some(ref pool) = state.workspace_pool {
pool.invalidate_user(&user_id).await;
}References
- To prevent divergence between a cache and a persistent store, cache updates should only be performed after the update to the persistent store has succeeded.
| let deleted = store | ||
| .revoke_scope_grant(&user_id, &scope) | ||
| .await | ||
| .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
There was a problem hiding this comment.
Revoking a grant requires cache invalidation for the target user to ensure they lose access immediately. Per repository rules, this cache invalidation must occur after the persistent store update has succeeded to prevent data divergence.
let deleted = store
.revoke_scope_grant(&user_id, &scope)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if deleted {
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&user_id).await;
}
if let Some(ref pool) = state.workspace_pool {
pool.invalidate_user(&user_id).await;
}
Ok(Json(serde_json::json!({ "deleted": true })))
} else {References
- To prevent divergence between a cache and a persistent store, cache updates should only be performed after the update to the persistent store has succeeded.
| store | ||
| .set_scope_grant(&grantee, &scope, writable, Some(&user.user_id)) | ||
| .await | ||
| .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
There was a problem hiding this comment.
Similar to the admin handler, self-service grant updates must invalidate the grantee's cached identity and workspace. Ensure the cache is cleared only after the database write is successful to maintain consistency.
store
.set_scope_grant(&grantee, &scope, writable, Some(&user.user_id))
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&grantee).await;
}
if let Some(ref pool) = state.workspace_pool {
pool.invalidate_user(&grantee).await;
}References
- To prevent divergence between a cache and a persistent store, cache updates should only be performed after the update to the persistent store has succeeded.
| let deleted = store | ||
| .revoke_scope_grant(&grantee, &scope) | ||
| .await | ||
| .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
There was a problem hiding this comment.
Revocation via self-service must trigger cache invalidation for the grantee to prevent unauthorized access via stale cache entries. This invalidation should follow the successful database revocation.
let deleted = store
.revoke_scope_grant(&grantee, &scope)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if deleted {
if let Some(ref db_auth) = state.db_auth {
db_auth.invalidate_user(&grantee).await;
}
if let Some(ref pool) = state.workspace_pool {
pool.invalidate_user(&grantee).await;
}
Ok(Json(serde_json::json!({ "deleted": true })))
} else {References
- To prevent divergence between a cache and a persistent store, cache updates should only be performed after the update to the persistent store has succeeded.
|
|
||
| CREATE TABLE IF NOT EXISTS scope_grants ( | ||
| user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| scope TEXT NOT NULL, |
There was a problem hiding this comment.
The scope column is intended to represent a user_id for cross-user access, but it lacks a foreign key constraint to the users table. Adding REFERENCES users(id) ON DELETE CASCADE ensures data integrity and automatically cleans up grants if the target user is deleted. Note that the PR description mentions that the PG schema has these constraints, but they are missing from this migration file.
scope TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,| let writable = body | ||
| .get("writable") | ||
| .and_then(|v| v.as_bool()) | ||
| .unwrap_or(false); |
There was a problem hiding this comment.
The admin handler should validate that both the user_id (grantee) and the scope (target user) actually exist in the database. Creating grants for non-existent users results in orphaned records that have no effect.
| let writable = body | |
| .get("writable") | |
| .and_then(|v| v.as_bool()) | |
| .unwrap_or(false); | |
| let writable = body | |
| .get("writable") | |
| .and_then(|v| v.as_bool()) | |
| .unwrap_or(false); | |
| require_user_exists(store.as_ref(), &user_id).await?; | |
| require_user_exists(store.as_ref(), &scope).await?; | |
serrrfirat
left a comment
There was a problem hiding this comment.
Paranoid Security Review — NEEDS CHANGES
PR: #2421 — Scope grants for cross-user read/write access
Reviewer context: Automated deep security review
Critical
No self-grant prevention. Neither endpoint prevents a user from granting themselves access to arbitrary scopes. The admin endpoint has no scope ownership check at all — an admin can grant anyone access to any scope.
Scope column not validated against existing users. scope TEXT NOT NULL has no FK constraint. Admins can create grants for non-existent scopes — phantom grants that activate if a matching user is later created. The self-service endpoint validates indirectly via check_scope_access but the admin path does not.
High
Workspace cache prevents grant revocation from taking effect. WorkspacePool caches workspaces in a HashMap<String, Arc<Workspace>> with NO TTL and no invalidation. Revoking a grant in the DB does nothing until server restart. Even when the auth cache expires (60s TTL), the workspace cache still serves the old workspace with old grants baked in. This means revoked access continues to work indefinitely.
WorkspaceResolver::resolve() bypasses scope grants entirely. The WorkspaceResolver trait implementation creates workspaces using only user_id without access to UserIdentity, so scope grants are never applied through this code path.
No time-bounded grants. Grants have created_at but no expires_at. Once granted, access persists forever until explicitly revoked.
Writers can revoke other writers' grants. scope_grant_revoke_handler uses check_scope_access() which allows any writable user to revoke any grant — including grants made by the scope owner or other delegates. No check on granted_by.
Medium
- Admin endpoint doesn't validate grantee user exists.
- No rate limiting on grant creation.
- Grant operations bypass
ToolDispatcher::dispatch()— no audit trail (violates "Everything Goes Through Tools" principle). - Circular grant relationships not detected.
Summary
The SQL is parameterized (no injection risk), admin auth is properly enforced, and the DB schema is reasonable. But the missing workspace cache invalidation is the most immediately exploitable issue — revoked grants should actually take effect. Add cache invalidation, expires_at, scope existence validation, and writer revocation restrictions before merge.
- Cache invalidation: invalidate DbAuthenticator + WorkspacePool caches after every set/revoke operation (4 call sites) so grants take effect immediately instead of waiting for 60s TTL - Self-grant prevention: reject user_id == scope in admin and grantee == scope in self-service endpoints (400 BAD_REQUEST) - Scope validation: validate scope exists via require_user_exists() in the admin set handler - Writer revocation restriction: non-owner writers can only revoke grants where granted_by matches their user_id; owners can revoke any - Add expires_at column: optional TIMESTAMPTZ on scope_grants, filtered at auth time so expired grants stop taking effect immediately - Scope FK: add REFERENCES users(id) ON DELETE CASCADE to scope column in postgres migration - WorkspaceResolver bypass: document as known limitation that resolve(user_id) creates workspaces without scope grants since it lacks UserIdentity Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds 27 new tests across three tiers: DB-level (7 tests in scope_grants.rs): - expired grants stored in DB (filtering is auth-layer) - get_scope_grant field round-trip - has_writable_grant ignores expiry at DB layer - revoke_by_granter with NULL granted_by - multiple grants per user - upsert changes both writable and expires_at - list_scope_grants_for_scope with multiple grantees Auth-level (4 tests in auth.rs): - expired grants filtered during DbAuthenticator::authenticate() - all-expired grants yield empty scopes - writable grants populate write_scopes separately - no grants yield empty scopes Handler-level (16 tests in handlers/scope_grants.rs): Admin: self-grant prevention (400), scope validation (404), set happy path, set with expires_at, revoke happy path, revoke non-existent (404), by-scope listing, set+get round-trip, revoke+get round-trip Self-service: self-grant prevention, writer cannot grant writable (403), writer CAN grant read-only, writer revocation restriction (only own grants), owner can revoke any grant, no-access returns 403, my-grants listing Includes manual curl test plan as inline comments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both admin and self-service set endpoints now include expires_at in the response body so callers don't need a separate GET. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Review feedback addressed + validation resultsAll items from both reviews (serrrfirat security review + gemini code review) are addressed. Changes made
Test coverage (30 tests)
Manual validationRebuilt binary, started fresh instance, ran all checks: # Setup
DATABASE_BACKEND=libsql LIBSQL_PATH=/tmp/test.db \
GATEWAY_PORT=9902 GATEWAY_AUTH_TOKEN=boot \
HTTP_HOST=127.0.0.1 HTTP_PORT=9903 \
cargo run --no-default-features --features libsql -- --no-onboard
# Create users
ALICE=$(curl -sf -X POST localhost:9902/api/admin/users \
-H "Authorization: Bearer boot" -H "Content-Type: application/json" \
-d '{"display_name":"Alice","role":"admin"}')
ALICE_ID=$(echo $ALICE | jq -r .id); ALICE_TOK=$(echo $ALICE | jq -r .token)
# (same for Household, Bob, Carol)
# Grant alice writable to household
curl -sf -X PUT "localhost:9902/api/admin/users/$ALICE_ID/scope-grants/$HOUSE_ID" \
-H "Authorization: Bearer $ALICE_TOK" -H "Content-Type: application/json" \
-d '{"writable": true}'
# 1. Self-grant prevention (expect 400)
curl -s -o /dev/null -w "%{http_code}" \
-X PUT "localhost:9902/api/admin/users/$ALICE_ID/scope-grants/$ALICE_ID" \
-H "Authorization: Bearer $ALICE_TOK" -H "Content-Type: application/json" \
-d '{"writable": false}'
# → 400
# 2. Scope validation (expect 404)
curl -s -o /dev/null -w "%{http_code}" \
-X PUT "localhost:9902/api/admin/users/$ALICE_ID/scope-grants/nonexistent-user" \
-H "Authorization: Bearer $ALICE_TOK" -H "Content-Type: application/json" \
-d '{"writable": false}'
# → 404
# 3. Grant with expires_at (set response must include expires_at)
curl -sf -X PUT "localhost:9902/api/admin/users/$BOB_ID/scope-grants/$HOUSE_ID" \
-H "Authorization: Bearer $ALICE_TOK" -H "Content-Type: application/json" \
-d '{"writable": false, "expires_at": "2026-12-31T23:59:59Z"}' | jq .
# → {"expires_at":"2026-12-31T23:59:59+00:00","scope":"...","user_id":"...","writable":false}
# 4a. Writer cannot grant writable (expect 403)
curl -s -o /dev/null -w "%{http_code}" \
-X PUT "localhost:9902/api/scope-grants/$HOUSE_ID/$BOB_ID" \
-H "Authorization: Bearer $ALICE_TOK" -H "Content-Type: application/json" \
-d '{"writable": true}'
# → 403
# 4b. Writer CAN grant read-only (expect 200)
curl -s -o /dev/null -w "%{http_code}" \
-X PUT "localhost:9902/api/scope-grants/$HOUSE_ID/$BOB_ID" \
-H "Authorization: Bearer $ALICE_TOK" -H "Content-Type: application/json" \
-d '{"writable": false}'
# → 200
# 4c. Writer revocation restriction
# Alice grants Carol:
curl -sf -X PUT "localhost:9902/api/scope-grants/$HOUSE_ID/$CAROL_ID" \
-H "Authorization: Bearer $ALICE_TOK" -H "Content-Type: application/json" \
-d '{"writable": false}'
# Bob (writer, didn't create Carol's grant) tries to revoke (expect 404):
curl -s -o /dev/null -w "%{http_code}" \
-X DELETE "localhost:9902/api/scope-grants/$HOUSE_ID/$CAROL_ID" \
-H "Authorization: Bearer $BOB_TOK"
# → 404
# Alice (created the grant) revokes Carol (expect 200):
curl -s -o /dev/null -w "%{http_code}" \
-X DELETE "localhost:9902/api/scope-grants/$HOUSE_ID/$CAROL_ID" \
-H "Authorization: Bearer $ALICE_TOK"
# → 200
# 5. Cache invalidation (no restart needed)
curl -sf -X PUT "localhost:9902/api/admin/users/$BOB_ID/scope-grants/$HOUSE_ID" \
-H "Authorization: Bearer $ALICE_TOK" -H "Content-Type: application/json" \
-d '{"writable": false}'
curl -sf localhost:9902/api/scope-grants -H "Authorization: Bearer $BOB_TOK" | jq '.grants | length'
# → 1
curl -sf -X DELETE "localhost:9902/api/admin/users/$BOB_ID/scope-grants/$HOUSE_ID" \
-H "Authorization: Bearer $ALICE_TOK"
curl -sf localhost:9902/api/scope-grants -H "Authorization: Bearer $BOB_TOK" | jq '.grants | length'
# → 0 (immediate, no restart)Results |
|
I missed #1734 when I started this. Closing in favor of that. I'm going to branch off |
Problem
PR #1626 replaced env-var auth (
GATEWAY_USER_TOKENS) with DB-backed user management but dropped the mechanism for granting cross-user workspace access. There's no DB-backed way for one user to read or write another user's scope. This blocks shared data (a "household" user storing a grocery list multiple family members can edit) and multi-agent architectures (a specialized agent whose workspace data should be accessible to the users it serves).Solution
Add a
scope_grantstable and API that lets users be granted read or read-write access to another user's scope. Grants are resolved at auth time and wired into the workspace as additional read scopes and writable memory layers.The model is simple:
(user_id, scope, writable, expires_at). "Alice can read-write household" is(alice, household, true, NULL). Scopes are just user IDs. Independent of workspace entities in #1734.How it works
DbAuthenticatorqueriesscope_grantsat auth time, filters expired grants, populatesUserIdentity.workspace_read_scopesandworkspace_write_scopesWorkspacePoolapplies read scopes and converts write scopes into writable memory layers viaWorkspace::with_additional_memory_layers()DbAuthenticatorcache andWorkspacePoolcacheAuthorization model
Two levels:
/api/admin/...): full CRUD on any user's grants. Validates both grantee and scope user exist./api/scope-grants/...): scope owners and users with writable access can manage grants for that scope.Security controls:
granted_bymatch). Owners can revoke any grant.expires_atfield. Expired grants are filtered at auth time.scopecolumn referencesusers(id) ON DELETE CASCADE(postgres).DbAuthenticatorandWorkspacePoolcaches evicted immediately after every set/revoke.Known limitations
WorkspaceResolver::resolve()creates workspaces by user_id alone (noUserIdentity), so scope grants are not applied through that path. This affects non-web callers (CLI, memory tools). Web gateway usesget_or_create(&identity)which does apply grants. Documented in code.API
Request body for PUT:
{"writable": bool, "expires_at": "RFC3339 string (optional)"}Test plan
cargo clippy --no-default-features --features libsql -- -D warnings-- zero warningscargo check --all-features-- cleancargo test --lib -- scope_grant)Automated test coverage (30 tests)
DB layer (10 tests): CRUD lifecycle, upsert, expired grants stored, get single grant, has_writable with expiry, null granted_by revocation, multi-grant, multi-grantee listing
Auth layer (4 tests): Expired grants filtered during auth, all-expired returns empty scopes, writable populates write_scopes, no-grants baseline
Handler layer (16 tests): Admin self-grant prevention (400), admin scope validation (404), admin set/revoke happy paths, set with expires_at, self-service self-grant prevention (400), writer cannot grant writable (403), writer CAN grant read-only, writer revocation restriction (writer_b cannot revoke writer_a's grant), owner can revoke any grant, no-access returns 403, my-grants listing
Manual validation