Repository navigation
feat(core): organization memberships, one active org per request (ADR-056 §3.6) - #681
seanspeaks wants to merge 5 commits into
Conversation
…ADR-056 §3.6) - OrganizationMembership model (Mongo + Postgres schemas, Postgres migration), unique on (individualUserId, organizationUserId). - Membership repository (findMembership, addMembership upsert, listMembershipsForIndividual) for Mongo, Postgres and DocumentDB. The legacy IndividualUser.organizationId is read as an implicit membership at read time; no data migration, column kept. - EnsureOrganizationMembership use case: the token / x-frigg org is the active org; a non-member is added, organizationId is only set on a first link, never overwritten. Replaces assertSingleOrganization and the multi_org_unsupported 403; the x-frigg path adds a membership instead of re-linking (strictMembership / strictUserValidation still reject). Fixes the organizationUser vs organizationId mismatch and the two failing x-frigg tests. - First-login race: a create that loses to a unique constraint re-finds once. - User#getActiveOrganizationId; types, README and docs/guides note. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
…, orgRole)
- Strict membership: headers can no longer make anyone, a brand-new appUserId
included, a member of an organization that already existed. Only an
organization created by the request is exempt. strictUserValidation is a
deprecated alias (warned once at construction, boolean computed there).
- Repositories are atomic: findMembership returns the row or null, and the
legacy organizationId fallback moved to the domain (user/membership.js),
applied by EnsureOrganizationMembership with the person it already holds.
listMembershipsForIndividual dropped (no caller); listMembershipsForOrganization
added for S1.
- findIndividualUsersByOrganizationId unions membership rows with the legacy
pointer (FindIndividualUsersByOrganization), so a person in two orgs is
listed in both.
- linkIndividualToOrganizationIfUnset: the legacy pointer is set only while
null, decided in the database update (updateMany where organizationId null /
filtered DocumentDB update).
- role renamed orgRole, default MEMBER, in both schemas and the migration.
- EnsureOrganizationMembership is injected; buildUserAuthentication wires the
Lambda router and Management API v2 identically.
- One isUniqueViolation (database/unique-violation.js, codes only); insertOne
now throws with code 11000. The organization-create race retry is removed
(no unique index on appOrgId, so it could never fire).
- Prisma base class for the Mongo and Postgres membership adapters; Postgres
id parsing is a strict integer check ('12abc' is refused); a test pins the
compound-key name against both schemas.
Not done here (F5): active-organization scoping of ownership. Credentials,
entities and integrations are still owned by user.getId(), the individual under
the default primary, so the active org does not yet scope them.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
…legacy pointer
Prisma on MongoDB: `organizationId: null` matches only an explicit null, so
linkIndividualToOrganizationIfUnset never fired for an individual created
without the field. Match `OR [null, { isSet: false }]`. The DocumentDB raw
filter `{ organizationId: null }` already matches missing fields (commented);
Postgres is unchanged.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
There was a problem hiding this comment.
If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.
Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.
…t-mode semantics Record what F4 (#681) implemented: no backfill migration; User.organizationId is a legacy pointer read as an implicit membership; orgRole defaults to MEMBER; strictMembership refuses any non-member of a pre-existing org. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
…ng them A brand-new appUserId naming an existing organization was refused with 400 only after createMissingAppUsers had persisted the individual, leaving an orphan on every refused request. Decide the refusal before creating anything. The ensure-step refusal for an existing non-member is unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ce2a674. Configure here.
listMembershipsForOrganization (and the legacy-pointer findIndividualUsersByOrganizationId it is unioned with) used findMany, which returns only the first cursor batch (~101 documents), so members of a large organization were silently dropped. Use findManyDrained. A test serves 2,500 documents over several batches through the real documentdb-utils. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
|
findEntitiesByOwnership and findIntegrationsByOwnership read only the first cursor batch (findMany returns cursor.firstBatch), so lists past ~101 documents lost rows, the same defect #681 fixed for memberships. Both now use findManyDrained. The bulk credential read behind entity lists is drained too, or entities past the first batch of the $in read came back without their credential. Tested over a fake server holding 2500 documents per collection (more than one getMore); each of the three reads reverted to findMany fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
Frigg 2.0.1 review (Stack A) — COMMENT onlyVerdict (would): would approve Reviewed ADR-056 §3.6 memberships: active org from JWT / BlockingNone for cross-tenant access assuming the BFF shared secret is not browser-exposed. Should-fix
Nits
No formal approve / request-changes on this review. |




Summary
This implements ADR-056 §3.6. A person can belong to many organizations, and each request runs as one active org: the org claim on the adopter JWT, or
x-frigg-apporgidon the header path. This replaces the interim 403multi_org_unsupportedfrom #678, and it fixes the long-standing defect where header auth re-linked a person's singleorganizationId.This is stacked on #678 (
claude/internal-mcp-02-adopter-jwt).Model and repositories
OrganizationMembershipis a new model(individualUserId, organizationUserId, orgRole default MEMBER, createdAt), unique on the pair. It is added to both Prisma schemas, with the Postgres migration20261015000000_add_organization_membership. That migration matchesprisma migrate diff; it has not been applied to any database.OrganizationMembershipRepositoryPrismabase; DocumentDB is separate) are atomic:findMembershipreturns the row or null.addMembershipis an idempotent upsert that recovers from unique races.listMembershipsForOrganization.'12abc'is refused.database/unique-violation.jsholds the singleisUniqueViolationcheck (P2002 or 11000, by code). DocumentDBinsertOnenow throws withcode: 11000.Domain and use cases
user/membership.jstreats a person's legacyorganizationIdas an implicit membership (implicitMembership). This is read-time and needs no data migration. It deviates from the ADR, which has the migration write rows; ADR-056 will be updated to match.EnsureOrganizationMembership. It adds the person to the active org if needed. It never overwritesorganizationId, which is only set on first link, through a conditional update (linkIndividualToOrganizationIfUnset, which matchesnullor unset on MongoDB).ensureOrganizationMembership.User.getActiveOrganizationId()returns the request's org, never the legacy pointer.organizationId(the repositories never returnedorganizationUser), which makes 2 previously failing tests pass.strictMembershiprefuses any non-member of an org that existed before the request, including a brand-newappUserId. Only an org created by that same request is exempt.strictUserValidationstays as a deprecated alias. It logs a warning once at construction, because its meaning has changed.FindIndividualUsersByOrganization. "Members of org X" now combines legacy-pointer people with membership rows, so a person in two orgs is listed in both.user/create-user-authentication.jsbuilds the bearer, x-frigg and adopter use cases once.integration-routerand the v2 dependencies both use it.Not in this PR (F5)
Credentials, entities and integrations are still owned by
user.getId(), which is the individual under the defaultprimary. The active org does not scope them yet. F5 addsorganizationIdto Entity/Integration and scoped lists.docs/guides/organization-memberships.mdsays so.Known limits
appOrgIdhas no unique index.Test plan
packages/corerun, with no-op global setup because the Mongo binaries can't run here: 14 suites and 61 tests fail, against 15 and 63 on feat(core): adopter-token (JWT) authentication #678. Compared by name, the difference is exactly the 2 x-frigg tests that now pass. 3061 pass.prisma validatepasses for both schemas.migrate diffmatches the migration.isSetbranch), domain fallback,orgRoledefault, strict id parse, unique-violation codes, and compound-key name pinned against the schema files.🤖 Generated with Claude Code
https://claude.ai/code/session_01HyrF4oqcTdK2AtvcrocxE6
Generated by Claude Code
Note
High Risk
Changes adopter JWT and x-frigg authentication to auto-create memberships and redefines strictUserValidation; incorrect membership or strict-mode behavior could admit or deny the wrong org context.
Overview
Implements multi-organization membership (ADR-056 §3.6): each request has one active organization from the JWT
org_idclaim orx-frigg-appOrgId, and people can belong to several orgs without moving the legacyIndividualUser.organizationIdpointer.Adds an
OrganizationMembershiptable/model (Postgres migration + both Prisma schemas), repositories for Postgres/Mongo/DocumentDB, andEnsureOrganizationMembershipwired into adopter JWT and x-frigg auth viabuildUserAuthentication. Existing users are covered by a read-time implicit membership from the legacy pointer (implicitMembershipinuser/membership.js), not a backfill.403 multi_org_unsupportedis removed; first-time org access creates an idempotent membership and storesorgRolefrom the token on first join. x-frigg stops re-linkingorganizationIdand gainsstrictMembership(plus deprecatedstrictUserValidationas an alias) to block header-based joins to pre-existing orgs.User.getActiveOrganizationId()reflects the request org;findIndividualUsersByOrganizationIdunions membership rows with legacy-pointer users. Supporting changes includeisUniqueViolation/ race recovery on user create,linkIndividualToOrganizationIfUnset, and DocumentDBfindManyDrainedfor large org lists. Docs:docs/guides/organization-memberships.mdand README updates. F5 (scoping credentials/entities by active org) is explicitly out of scope.Reviewed by Cursor Bugbot for commit 2df9ef0. Bugbot is set up for automated code reviews on this repo. Configure here.
📦 Published PR as canary version:
2.0.0--canary.681.2df9ef0.0✨ Test out this PR locally via:
npm install @friggframework/admin-scripts@2.0.0--canary.681.2df9ef0.0 npm install @friggframework/core@2.0.0--canary.681.2df9ef0.0 npm install @friggframework/devtools@2.0.0--canary.681.2df9ef0.0 npm install @friggframework/eslint-config@2.0.0--canary.681.2df9ef0.0 npm install @friggframework/prettier-config@2.0.0--canary.681.2df9ef0.0 npm install @friggframework/schemas@2.0.0--canary.681.2df9ef0.0 npm install @friggframework/serverless-plugin@2.0.0--canary.681.2df9ef0.0 npm install @friggframework/test@2.0.0--canary.681.2df9ef0.0 npm install @friggframework/ui@2.0.0--canary.681.2df9ef0.0 # or yarn add @friggframework/admin-scripts@2.0.0--canary.681.2df9ef0.0 yarn add @friggframework/core@2.0.0--canary.681.2df9ef0.0 yarn add @friggframework/devtools@2.0.0--canary.681.2df9ef0.0 yarn add @friggframework/eslint-config@2.0.0--canary.681.2df9ef0.0 yarn add @friggframework/prettier-config@2.0.0--canary.681.2df9ef0.0 yarn add @friggframework/schemas@2.0.0--canary.681.2df9ef0.0 yarn add @friggframework/serverless-plugin@2.0.0--canary.681.2df9ef0.0 yarn add @friggframework/test@2.0.0--canary.681.2df9ef0.0 yarn add @friggframework/ui@2.0.0--canary.681.2df9ef0.0