Skip to content

feat(core): organization memberships, one active org per request (ADR-056 §3.6) - #681

Open
seanspeaks wants to merge 5 commits into
claude/internal-mcp-02-adopter-jwtfrom
claude/internal-mcp-04-memberships
Open

seanspeaks wants to merge 5 commits into
claude/internal-mcp-02-adopter-jwtfrom
claude/internal-mcp-04-memberships

Conversation

@seanspeaks

@seanspeaks seanspeaks commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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-apporgid on the header path. This replaces the interim 403 multi_org_unsupported from #678, and it fixes the long-standing defect where header auth re-linked a person's single organizationId.

This is stacked on #678 (claude/internal-mcp-02-adopter-jwt).

Model and repositories

  • OrganizationMembership is a new model (individualUserId, organizationUserId, orgRole default MEMBER, createdAt), unique on the pair. It is added to both Prisma schemas, with the Postgres migration 20261015000000_add_organization_membership. That migration matches prisma migrate diff; it has not been applied to any database.
  • Repositories (Mongo and Postgres share an OrganizationMembershipRepositoryPrisma base; DocumentDB is separate) are atomic:
    • findMembership returns the row or null.
    • addMembership is an idempotent upsert that recovers from unique races.
    • listMembershipsForOrganization.
    • Postgres ids are parsed strictly, so '12abc' is refused.
  • database/unique-violation.js holds the single isUniqueViolation check (P2002 or 11000, by code). DocumentDB insertOne now throws with code: 11000.

Domain and use cases

  • Legacy pointer. user/membership.js treats a person's legacy organizationId as 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 overwrites organizationId, which is only set on first link, through a conditional update (linkIndividualToOrganizationIfUnset, which matches null or unset on MongoDB).
  • Both auth paths take the injected ensureOrganizationMembership.
    • User.getActiveOrganizationId() returns the request's org, never the legacy pointer.
    • The x-frigg path no longer re-links. It also reads organizationId (the repositories never returned organizationUser), which makes 2 previously failing tests pass.
  • strictMembership refuses any non-member of an org that existed before the request, including a brand-new appUserId. Only an org created by that same request is exempt.
    • strictUserValidation stays 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.js builds the bearer, x-frigg and adopter use cases once. integration-router and 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 default primary. The active org does not scope them yet. F5 adds organizationId to Entity/Integration and scoped lists. docs/guides/organization-memberships.md says so.

Known limits

  • Two concurrent first logins for a brand-new org can still create the org twice, because appOrgId has no unique index.
  • On Mongo, deleting a user leaves its membership rows behind (no cascade).
  • A legacy pointer can dangle after its org is deleted.
  • These are documented in the guide.

Test plan

  • Full packages/core run, 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 validate passes for both schemas. migrate diff matches the migration.
  • ESLint: 0 errors.
  • Two independent reviews (spec/test and clean code), with the findings fixed. Across the rounds, 47 mutations were killed.
    • Covered: strict-mode holes, conditional pointer link per adapter (including the Mongo isSet branch), domain fallback, orgRole default, strict id parse, unique-violation codes, and compound-key name pinned against the schema files.
  • Not tested against a real database: the Prisma adapters are tested against a mocked client, with the query shape asserted. F6 adds a real-Postgres isolation suite.

🤖 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_id claim or x-frigg-appOrgId, and people can belong to several orgs without moving the legacy IndividualUser.organizationId pointer.

Adds an OrganizationMembership table/model (Postgres migration + both Prisma schemas), repositories for Postgres/Mongo/DocumentDB, and EnsureOrganizationMembership wired into adopter JWT and x-frigg auth via buildUserAuthentication. Existing users are covered by a read-time implicit membership from the legacy pointer (implicitMembership in user/membership.js), not a backfill. 403 multi_org_unsupported is removed; first-time org access creates an idempotent membership and stores orgRole from the token on first join. x-frigg stops re-linking organizationId and gains strictMembership (plus deprecated strictUserValidation as an alias) to block header-based joins to pre-existing orgs.

User.getActiveOrganizationId() reflects the request org; findIndividualUsersByOrganizationId unions membership rows with legacy-pointer users. Supporting changes include isUniqueViolation / race recovery on user create, linkIndividualToOrganizationIfUnset, and DocumentDB findManyDrained for large org lists. Docs: docs/guides/organization-memberships.md and 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

…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

@claude claude 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.

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

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.

@seanspeaks seanspeaks added release Create a release when this pr is merged prerelease This change is available in a prerelease. labels Oct 15, 2026 — with Claude
seanspeaks pushed a commit that referenced this pull request Oct 15, 2026
…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
Comment thread packages/core/user/use-cases/get-user-from-x-frigg-headers.js
…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

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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
@sonarqubecloud

Copy link
Copy Markdown

seanspeaks pushed a commit that referenced this pull request Oct 16, 2026
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
@cursor

cursor Bot commented Oct 24, 2026

Copy link
Copy Markdown

Frigg 2.0.1 review (Stack A) — COMMENT only

Verdict (would): would approve

Reviewed ADR-056 §3.6 memberships: active org from JWT / x-frigg-appOrgId, add-not-move membership, legacy organizationId as implicit membership. Cross-tenant IDOR posture is acceptable given the shared secret stays server-side.

Blocking

None for cross-tenant access assuming the BFF shared secret is not browser-exposed.

Should-fix

  1. Duplicate org users for one appOrgId (race) — app-user-helpers documented race with no unique index. Concurrent first logins for the same org can create two ORGANIZATION users; later lookups pick one arbitrarily and split members/entities across “same” tenant. Add a partial unique index on (appOrgId) WHERE type = ORGANIZATION (and best-effort Mongo/DocumentDB equivalents), or transactional find-or-create with a recovery path.
  2. Default non-strict header join — Any holder of the shared secret can add any appUserId to any existing org. Expected for a BFF; catastrophic if the secret reaches a browser. Product-profile scaffolds / docs should default or strongly recommend user.strictMembership: true, and state that BFFs must drop client x-frigg-* and only assert IdP-verified memberships.

Nits

  • strictUserValidation alias meaning change is easy to misconfigure; keep the startup warn.

No formal approve / request-changes on this review.

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

Labels

prerelease This change is available in a prerelease. release Create a release when this pr is merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants