Skip to content

feat(graphql-server): stamp actor entity attribution on token sessions - #1857

Closed
pyramation wants to merge 2 commits into
mainfrom
feat/token-session-entity-attribution
Closed

pyramation wants to merge 2 commits into
mainfrom
feat/token-session-entity-attribution

Conversation

@pyramation

@pyramation pyramation commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes https://github.com/constructive-io/constructive-planning/issues/2107.

Authenticated requests on the public GraphQL surface set jwt.claims.user_id / jwt.claims.principal_id but never jwt.claims.entity_id / jwt.claims.entity_type — the anonymous branch (database), the private in-cluster branch (X-Entity-* headers) and the sync gateway all stamp the pair, the token branch didn't. So a signed-in user calling startExecution produced an execution with no entity attribution and the agent run was refused downstream.

The pair is now resolved server-side, the same way the sync gateway does it:

auth middleware (per request, after the RLS authenticate() row):
  if token.user_id && api.databaseId && hasEntityModel(pool, api.databaseId):   // probed per (dbname, databaseId), 60s TTL
    req.actorEntity = app_scope.actor_entity(api.databaseId, token.user_id)     // 60s cache per db+actor
    → unresolvable actor = request fails (INTERNAL_FAILURE), never proceeds entityless

graphile context, token branch:
  pgSettings['jwt.claims.entity_id']   = req.actorEntity.entityId
  pgSettings['jwt.claims.entity_type'] = req.actorEntity.entityType
  • Attribution only; no change to roles, RLS or capabilities. A principal credential resolves to its owner's pair (app_scope.actor_entity does that), so a principal is never an entity of its own. Nothing is accepted from client headers or mutation arguments on the public surface.
  • hasEntityModel = app_scope.actor_entity is deployed and metaschema_modules_public.users_module has a row for this database_id. A database with no users module (bare RLS module, the auth-only graphql/server-test fixtures) has no entity for an actor to carry, so no pair is stamped and it behaves exactly as before — that was the error-events.integration.test.ts failure on the first head, where actor_entity raised ACTOR_ENTITY_UNRESOLVED: … installs no users module. The probe is re-run on a 60s TTL so a users module deployed while the server runs starts attributing without a restart.
  • New middleware/actor-entity.ts (createActorEntityResolver, hasEntityModel, pgActorEntityQuery) + tests covering user, principal→owner, unresolvable, no-app_scope, app_scope-without-users-module and anonymous paths. graphql/server unit suite and both error-events* integration suites pass locally.

Follow-up in constructive-db once this publishes: regression test that startExecution over the public API as a user yields an execution/run carrying the entity pair.

Link to Devin session: https://app.devin.ai/sessions/3c993d055ffb47f6be27862718a9cd42
Open in Devin Desktop: https://app.devin.ai/desktop/session/3c993d055ffb47f6be27862718a9cd42?variant=devin
Requested by: @pyramation

Authenticated public GraphQL requests set jwt.claims.user_id/principal_id
but never jwt.claims.entity_id/entity_type, so work created by a signed-in
user (e.g. startExecution) had no entity attribution. Resolve the pair
server-side via app_scope.actor_entity(database_id, user_id) in the auth
middleware (principal credentials resolve to their owner) and stamp it in
the token branch of the graphile context. Tenant databases without
app_scope are unchanged; when it is present an unresolvable actor fails
the request rather than proceeding entityless.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 1 medium

💬 Inline comments (1)

  • 🟡 Negative app_scope probe cached for process lifetime — auth.ts:38
🧹 Nitpicks (2) — 🟢 2 low
  • 🟢 Expired ActorEntity cache entries are never evicted (actor-entity.ts:48) — createActorEntityResolver never deletes entries from its cache Map: an expired hit falls through to re-query, and cache.set only overwrites the same key (graphql/server/src/middleware/actor-entity.ts:44-60), so keys never looked up again stay forever.
  • 🟢 Dev-mode error details leak internal actor/database ids (auth.ts:147) — When entity resolution fails, ActorEntityError carries a message embedding the internal actor id and database id (graphql/server/src/middleware/actor-entity.ts:54-56), and the generic catch echoes raw e.message to the client as details when NODE_ENV=development (graphql/server/src/middleware/auth.ts:147-156).

This PR introduces entity attribution for the GraphQL server: a resolver probes whether the tenant database has app_scope.actor_entity, resolves the actor's owning entity with a short TTL cache, and stamps entity_id/entity_type into the DB session JWT claims used for billing attribution.

Files Change
graphql/server/src/middleware/actor-entity.ts New per-request actor→entity resolver with a TTL cache and typed ActorEntityError.
graphql/server/src/middleware/auth.ts Wires the resolver into the auth middleware, with per-database capability probing and hard-fail handling when attribution is required but unresolvable.
graphql/server/src/middleware/graphile.ts Propagates req.actorEntity into the PostGraphile jwtClaims so the DB session carries the entity context.
graphql/server/src/middleware/types.ts Extends request typings for the new actor-entity fields.
graphql/server/src/middleware/__tests__/actor-entity.test.ts Tests for resolution, caching, and the fail-closed behavior.

Reviewed commit: 23b48dd

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Adds actor-to-entity attribution middleware that resolves the acting entity per request and stamps it into the PostGraphile DB session claims for billing attribution.

Key findings

  • 🟡 Negative app_scope probe cached for process lifetime — auth.ts:38

Comment thread graphql/server/src/middleware/auth.ts Outdated
@blacksmith-sh

This comment has been minimized.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Closing unmerged. app_scope.actor_entity maps users.type (a row-kind enum) onto entity_type (a scope name — database, org, team, …) and can only ever return "the actor is its own entity", so stamping it on token sessions was answering the wrong question. The execution row is the only thing that took its entity from claims; every sibling row (invocations, jobs, schedules) takes it from the scope frame. The fix lands in constructive-db (start_invocation_execution stamps the frame's entity), and no claim is needed on the public surface. Analysis: https://github.com/constructive-io/constructive-planning/issues/2107#issuecomment-5803682228

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant