Skip to content

feat: scope grants for cross-user read/write access - #2421

Closed
standardtoaster wants to merge 6 commits into
nearai:stagingfrom
standardtoaster:feat/scope-grants
Closed

standardtoaster wants to merge 6 commits into
nearai:stagingfrom
standardtoaster:feat/scope-grants

Conversation

@standardtoaster

@standardtoaster standardtoaster commented Apr 13, 2026 •

Copy link
Copy Markdown
Contributor

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_grants table 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

  1. Grant set via admin API or self-service endpoint
  2. DbAuthenticator queries scope_grants at auth time, filters expired grants, populates UserIdentity.workspace_read_scopes and workspace_write_scopes
  3. WorkspacePool applies read scopes and converts write scopes into writable memory layers via Workspace::with_additional_memory_layers()
  4. Cache invalidation: every set/revoke call immediately evicts the affected user from both DbAuthenticator cache and WorkspacePool cache

Authorization model

Two levels:

  • Admin (/api/admin/...): full CRUD on any user's grants. Validates both grantee and scope user exist.
  • Self-service (/api/scope-grants/...): scope owners and users with writable access can manage grants for that scope.

Security controls:

  • Self-grant prevention: cannot grant yourself access to your own scope (400)
  • Escalation guard: only the scope owner can grant writable access. Writers can only grant read-only.
  • Writer revocation restriction: writers can only revoke grants they created (granted_by match). Owners can revoke any grant.
  • Grant expiry: optional expires_at field. Expired grants are filtered at auth time.
  • Scope FK: scope column references users(id) ON DELETE CASCADE (postgres).
  • Cache invalidation: DbAuthenticator and WorkspacePool caches evicted immediately after every set/revoke.

Known limitations

  • WorkspaceResolver bypass: WorkspaceResolver::resolve() creates workspaces by user_id alone (no UserIdentity), so scope grants are not applied through that path. This affects non-web callers (CLI, memory tools). Web gateway uses get_or_create(&identity) which does apply grants. Documented in code.

API

# Admin
GET    /api/admin/users/{id}/scope-grants            -- list user's grants
PUT    /api/admin/users/{id}/scope-grants/{scope}     -- set grant
DELETE /api/admin/users/{id}/scope-grants/{scope}     -- revoke
GET    /api/admin/scope-grants/by-scope/{scope}       -- who has access

# Self-service
GET    /api/scope-grants                              -- my grants
GET    /api/scope-grants/{scope}                      -- scope members
PUT    /api/scope-grants/{scope}/{grantee}             -- grant access
DELETE /api/scope-grants/{scope}/{grantee}             -- revoke access

Request body for PUT: {"writable": bool, "expires_at": "RFC3339 string (optional)"}

Test plan

  • cargo clippy --no-default-features --features libsql -- -D warnings -- zero warnings
  • cargo check --all-features -- clean
  • 30 scope grant tests pass (cargo test --lib -- scope_grant)
  • 4710 existing tests pass

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

# 1. Self-grant prevention (expect 400)
curl -s -o /dev/null -w "%{http_code}" \
  -X PUT http://localhost:3003/api/admin/users/alice/scope-grants/alice \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"writable": false}'

# 2. Scope validation (expect 404)
curl -s -o /dev/null -w "%{http_code}" \
  -X PUT http://localhost:3003/api/admin/users/alice/scope-grants/nonexistent \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"writable": false}'

# 3. Grant with expires_at
curl -sf -X PUT http://localhost:3003/api/admin/users/alice/scope-grants/shared \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"writable": false, "expires_at": "2026-12-31T23:59:59Z"}' | jq .

# 4. Writer revocation restriction
# writer_a grants bob access:
curl -sf -X PUT http://localhost:3003/api/scope-grants/shared/bob \
  -H "Authorization: Bearer $WRITER_A_TOKEN" -H "Content-Type: application/json" \
  -d '{"writable": false}'
# writer_b tries to revoke (expect 404):
curl -s -o /dev/null -w "%{http_code}" \
  -X DELETE http://localhost:3003/api/scope-grants/shared/bob \
  -H "Authorization: Bearer $WRITER_B_TOKEN"
# writer_a revokes (expect 200):
curl -s -o /dev/null -w "%{http_code}" \
  -X DELETE http://localhost:3003/api/scope-grants/shared/bob \
  -H "Authorization: Bearer $WRITER_A_TOKEN"

# 5. Cache invalidation (grant, verify access, revoke, verify removed)
curl -sf -X PUT http://localhost:3003/api/admin/users/alice/scope-grants/shared \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"writable": false}'
# Verify alice can read shared scope (should see shared data):
curl -sf http://localhost:3003/api/memory/tree \
  -H "Authorization: Bearer $ALICE_TOKEN" | jq .
# Revoke:
curl -sf -X DELETE http://localhost:3003/api/admin/users/alice/scope-grants/shared \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# Verify alice can no longer see shared scope (immediate, no restart):
curl -sf http://localhost:3003/api/memory/tree \
  -H "Authorization: Bearer $ALICE_TOKEN" | jq .

standardtoaster and others added 3 commits April 13, 2026 17:24
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>
@github-actions github-actions Bot added scope: channel/web Web gateway channel scope: db Database trait / abstraction scope: db/postgres PostgreSQL backend scope: db/libsql libSQL / Turso backend scope: workspace Persistent memory / workspace DB MIGRATION PR adds or modifies PostgreSQL or libSQL migration definitions size: XL 500+ changed lines risk: high Safety, secrets, auth, or critical infrastructure contributor: regular 2-5 merged PRs labels Apr 13, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +56 to +59
store
.set_scope_grant(&user_id, &scope, writable, Some(&admin.user_id))
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
  1. 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.

Comment on lines +79 to +82
let deleted = store
.revoke_scope_grant(&user_id, &scope)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
  1. 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.

Comment on lines +248 to +251
store
.set_scope_grant(&grantee, &scope, writable, Some(&user.user_id))
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
  1. 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.

Comment on lines +271 to +274
let deleted = store
.revoke_scope_grant(&grantee, &scope)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
  1. 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.

Comment thread migrations/V24__scope_grants.sql Outdated

CREATE TABLE IF NOT EXISTS scope_grants (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope TEXT NOT NULL,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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,

Comment on lines +51 to +54
let writable = body
.get("writable")
.and_then(|v| v.as_bool())
.unwrap_or(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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 serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@serrrfirat

Copy link
Copy Markdown
Collaborator

Thanks for the PR! I wonder if we could combine this approaches with these #2381 #1734 ?

standardtoaster and others added 3 commits April 14, 2026 12:46
- 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>
@standardtoaster

Copy link
Copy Markdown
Contributor Author

Review feedback addressed + validation results

All items from both reviews (serrrfirat security review + gemini code review) are addressed.

Changes made

# Issue Fix
1 Cache invalidation (blocker) Added WorkspacePool::invalidate_user(), invalidate_caches() helper called at all 4 mutation sites
2 Self-grant prevention user_id == scope check returns 400 on both admin and self-service
3 Scope validation in admin require_user_exists(store, &scope) added to admin set handler
4 Writer revocation restriction New revoke_scope_grant_by_granter() method; non-owner writers can only revoke grants they created
5 expires_at field Full stack: migration, model, both backends, auth filtering, handler parsing, echoed in set response
6 Scope FK scope REFERENCES users(id) ON DELETE CASCADE in postgres migration
7 WorkspaceResolver bypass Documented as known limitation (non-web path, no UserIdentity)

Test coverage (30 tests)

cargo test --lib -- scope_grant
  • DB layer (10): CRUD, expiry, upsert, revoke-by-granter, null granter, multi-grant
  • Auth layer (4): Expired grant filtering, writable scope population, empty baselines
  • Handler layer (16): Self-grant prevention, scope validation, writer escalation guard, writer revocation restriction, owner can revoke any, no-access 403, happy paths

Manual validation

Rebuilt 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

1. Self-grant prevention (expect 400) .............. 400 ✓
2. Scope validation non-existent (expect 404) ...... 404 ✓
3. Set response includes expires_at ................ ✓
   List response includes expires_at ............... ✓
4a. Writer cannot grant writable (expect 403) ...... 403 ✓
4b. Writer CAN grant read-only (expect 200) ........ 200 ✓
4c. Writer B cannot revoke Writer A's grant (404) .. 404 ✓
    Writer A CAN revoke own grant (200) ............ 200 ✓
5. Cache invalidation:
   Grant Bob → grants=1 ........................... 1 ✓
   Revoke Bob → grants=0 (no restart) ............. 0 ✓

@standardtoaster

Copy link
Copy Markdown
Contributor Author

I missed #1734 when I started this. Closing in favor of that.

I'm going to branch off feat/1607-workspace-entities-db-users, work through the Apr 8 review items from @ilblackdragon and @zmanian, and open a PR against your branch. Let me know if you'd prefer a different setup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: regular 2-5 merged PRs DB MIGRATION PR adds or modifies PostgreSQL or libSQL migration definitions risk: high Safety, secrets, auth, or critical infrastructure scope: channel/web Web gateway channel scope: db/libsql libSQL / Turso backend scope: db/postgres PostgreSQL backend scope: db Database trait / abstraction scope: workspace Persistent memory / workspace size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants