Skip to content

Let resource servers look up memberships beyond their own organization, bound to the user's token - #265

Merged
jalexw merged 3 commits into
mainfrom
claude/adoring-carson-vhyfk6
Oct 18, 2026
Merged

jalexw merged 3 commits into
mainfrom
claude/adoring-carson-vhyfk6

Conversation

@jalexw

@jalexw jalexw commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Why

The schema registry can't show a user's organizations or check their membership in customer organizations:

  • In the browser: useMyOrganizations() / listMyOrganizationMemberships() ask for an access token whose audience is the auth server itself. Only the auth server's own app may hold one (validate-audience.ts), so this fails on every other app ("Failed to acquire access token for audience 'https://auth.schemavaults.com'").
  • On the server: GET /api/resource-server/organizations/{organization_id}/members/{uid}/role answers only for the organization that owns the calling API server. A multi-tenant service like the registry gets a 403 for every customer organization.

Rather than an admin service account or handing auth-server tokens to the registry's browser, this adds one narrow, admin-granted capability to the existing API-server-to-auth-server channel, bound to the user's own token.

What changed

Auth server

  • Migration 00045 adds API_SERVERS.organization_membership_visibility: owning_organization (the default, unchanged behaviour) or all_organizations. It is never set at creation.
  • New PUT /api/apis/{api_server_id}/organization-membership-visibility, for platform admins only (routeGuard: "admin"; refuses hardcoded API servers). There is also an admin-only switch card on /apis/[api_server_id].
  • Role endpoint: when an API server has the grant, it answers for other organizations, but only when X-Subject-Token carries an active access token of that user for the calling API server. Lookups in the API server's own organization work exactly as before.
  • New GET /api/resource-server/users/{uid}/organizations lists the user's memberships that the API server may see (the /api/me/organizations shape). X-Subject-Token is always required.
  • verifyResourceServerSubjectToken() reuses the RFC 7662 introspection pipeline with the API server as the caller: the token must decrypt with that API server's keyset, not be revoked or older than tokens_valid_after, the user must still authorize the app, and the app must still be connected. A leaked JWKS access key alone therefore cannot enumerate anyone's memberships.

Packages

  • app-definitions: the new field on the API server schema, plus getOrganizationMembershipAccess() / getOrganizationMembershipVisibility().
  • openapi-operations: new optional AuthPrincipal.listOrganizationMemberships.
  • auth-server-sdk: new listUserOrganizations(); isUserInOrganization(..., { subject_token }). Resolver principals gain listOrganizationMemberships(), and both lookups forward the token the request presented (bearer or cookie).
  • auth-client-sdk: new setApiServerOrganizationMembershipVisibility(). On any app other than the auth server, listMyOrganizationMemberships() now rejects immediately with MyOrganizationMembershipsUnavailableError instead of requesting a token it can never get.
  • The other packages get version bumps only, per the commit-changes cascade.

Docs: new resource-server-organization-memberships skill, a CLAUDE.md table row, and README updates for the SDK and openapi-operations.

Testing

  • bun run test: 22/22 workspace test tasks pass. New unit tests cover the app-definitions helpers, the SDK list/role helpers, the resolver forwarding the presented token (bearer and cookie), and the client SDK fail-fast and setter.
  • bun run typecheck (24/24) and bun run lint (0 errors) pass.
  • New Cypress spec resource_management/ResourceServerOrganizationMembershipVisibility.cy.ts; the new paths are pinned in api_contract/AuthServerOpenApiDocument.cy.ts. Not run locally (needs Docker), so CI will be its first run.
  • Live check against a locally booted test-environment auth server on Postgres 16, with migration 00045 applied: 37/37 checks pass. They cover the default/admin-only grant, cross-org role and list lookups with missing, garbage, other-user and valid subject tokens, the SDK helpers, a createSchemaVaultsAuthResolvers() principal calling both lookups, and a subject token becoming invalid once its app is disconnected. The admin card server-renders with the saved state.

Rollout

  1. Merge, so CI publishes the packages (auth-server-sdk 0.34.0, openapi-operations 0.5.0, app-definitions 0.15.0, ...).
  2. Deploy the auth server and run migration 00045.
  3. As a platform admin, set schemavaults-registry to all_organizations.
  4. Update the registry (schemavaults/schemas) to list memberships server-side through ctx.auth.listOrganizationMemberships().

🤖 Generated with Claude Code

https://claude.ai/code/session_01WdvoqYDdDzHXxxW4TJ4qTW


Generated by Claude Code

…dk:0.24.0, auth-react-provider:0.18.4, auth-ui:0.21.7, openapi-operations:0.5.0, auth-resource-server-codegen-templates:0.0.167, auth-server-sdk:0.34.0, trpc-backend-init:0.9.89, auth-server:0.57.0, e2e-auth-tests:0.12.15 - let resource servers look up memberships beyond their own organization, bound to the user's token

Client apps other than the auth server can never call /api/me/organizations:
only the auth server's own app may hold tokens for its audience. Resource
servers ask the auth server as their API server instead, but the role
endpoint only answered for the API server's own organization, so a
multi-tenant service (the schema registry) could check neither "my
organizations" nor membership of any customer organization.

- API servers gain organization_membership_visibility (migration 00045):
  `owning_organization` (default, current behaviour) or `all_organizations`,
  set only by platform admins via
  PUT /api/apis/{api_server_id}/organization-membership-visibility (and an
  admin-only card on the API server page).
- GET .../organizations/{organization_id}/members/{uid}/role answers for
  other organizations when the grant is set AND X-Subject-Token carries an
  active access token of that user for the calling API server (verified
  with the introspection pipeline: keyset, revocation, consent, app-to-API
  connection).
- New GET /api/resource-server/users/{uid}/organizations lists the user's
  memberships the API server may see; X-Subject-Token always required.
- Server SDK: listUserOrganizations(), isUserInOrganization() subject_token
  option; resolver principals gain listOrganizationMemberships() (new
  AuthPrincipal field in openapi-operations) and forward the presented
  token for both lookups.
- Client SDK: setApiServerOrganizationMembershipVisibility();
  listMyOrganizationMemberships() rejects with
  MyOrganizationMembershipsUnavailableError off the auth server instead of
  requesting a token it can never get.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdvoqYDdDzHXxxW4TJ4qTW
@jalexw jalexw self-assigned this Oct 17, 2026
…plicitly before using it

The resource-server membership endpoints read X-Subject-Token with a bare
truthiness check. parseSubjectTokenHeader() now decides absent / malformed /
token up front: a JWE in compact serialization (five non-empty base64url
segments) of at most 4096 characters (access tokens live in cookies), whose
protected header is a JSON object naming RSA-OAEP-256 and carrying enc,
keyset_id and aud. Malformed values are a 400 in the endpoints'
{ success, error } envelope; the role endpoint parses before verifying the
assertion and refuses a malformed header even on own-organization lookups.
Well-formed tokens that do not verify stay 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdvoqYDdDzHXxxW4TJ4qTW
…ation membership visibility at API server creation

POST /api/apis validates its body with the API server definition schema,
which now accepts organization_membership_visibility, so a creator could
send `all_organizations`. It was only dropped by omission (registerApiServer
inserts an explicit column list). The endpoint now refuses any value other
than `owning_organization` with 400, for platform admins too: the admin-only
PUT /api/apis/{api_server_id}/organization-membership-visibility stays the
only way to widen it.

The E2E spec pins that neither an organization owner (regular user) nor an
admin can create a widened API server, that nothing is stored when refused,
and that the default / omitted value still creates one at
`owning_organization`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdvoqYDdDzHXxxW4TJ4qTW
@jalexw
jalexw merged commit 24fa06d into main Oct 18, 2026
35 checks passed
@jalexw
jalexw deleted the claude/adoring-carson-vhyfk6 branch October 18, 2026 00:22
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.

2 participants