Skip to content

feat(api,web): org members, RBAC, invitations, and team page - #11

Merged
marcorivm merged 2 commits into
open-edition/01-tier1-ungatingfrom
open-edition/02-org-members-rbac
Aug 8, 2026
Merged

feat(api,web): org members, RBAC, invitations, and team page#11
marcorivm merged 2 commits into
open-edition/01-tier1-ungatingfrom
open-edition/02-org-members-rbac

Conversation

@marcorivm

@marcorivm marcorivm commented Aug 6, 2026

Copy link
Copy Markdown
Member

Organization membership backend plus the team UI. 47 files, +5,298/−36 — of which 2,355 lines are tests, so the review surface is ~2,900 lines.

  • org-member-service.ts, org-invitation-service.ts (+ their tests)
  • Routes: org/members, org/invitations (+ ~1,440 lines of route tests)
  • API-key middleware RBAC (middleware/auth/api-key.test.ts)
  • Web: /team page and components, /join/[token] invitation acceptance flow

Includes merge commit 62d04c3 (reconciliation stage B), which is why this branch points there rather than at b4d3282.

Stack

Split out of the original 381-file #8. Upstream catch-up (v1.42.0 → v1.44.0) already landed as #10, so main is now v1.44.0 and everything below is our own code.

main (v1.44.0, after #10)
 └─ #14  01-tier1-ungating              23 files    +73/-442
     └─ #11  02-org-members-rbac        47 files  +5298/-36
         └─ #12  03-user-groups         16 files  +2963/-1
             └─ #13  04-project-access  33 files  +7701/-234
                 └─ #15  05-gateway-org-scope       6 files  +1080/-176
                     └─ #16  06-gateway-conditions  23 files  +2292/-211
                         └─ #17  07-gateway-resource-scope  13 files  +1551/-20
                             └─ #18  08-web-org-policy      23 files  +2421/-384
                                 └─ #8   spend budgets      20 files  +2523/-35
                                     └─ #9   upstream-sync tooling  8 files  +823/-0

Review and merge in order, top to bottom. Roughly half of each diff is tests.

Reconciliation Stage B. OSS role resolver + rbac:true, /v1/org/members
(keyset list, suspend/reinstate, role update) and /v1/org/invitations on
the surviving eeRoutes seam (no shared app.ts edit), member project
provisioning with the access binding, link-based invitations, /team and
/join. +129 tests, no agent-group code, no migration.
# Conflicts:
#	apps/web/src/lib/init/api.ts
@marcorivm

Copy link
Copy Markdown
Member Author

What it actually does

The first real multi-user feature: an org admin can see a member directory, suspend/reinstate members, change roles between member and admin, and invite teammates via a shareable link. Recipients land on /join/[token], sign in, and are added to the org with the role the admin chose. Before this PR, OSS was single-user with no membership surface at all.

Underneath, it flips on CAPS.rbac for OSS and — critically — makes that flag mean something: it activates pre-existing but previously inert authorization code (canAccessProjectAsUser in middleware/auth/resolve.ts, the org-key admin re-check in middleware/auth/api-key.ts) that were no-ops in OSS because no role resolver backed them.

Why it exists

Upstream ships org membership/roles only in its closed cloud product. "Open edition" implies self-hosted orgs can have more than one human without everyone being a de-facto admin. It's also the foundation for #12, #13 and #18.

Reading order

  1. packages/api/src/services/org-role-resolver.ts (55 lines) — read first. ossRoleResolver.getUserRole() is the trust root for every downstream role check: reads OrganizationMember.role, treats status: "suspended" as no-role, treats an unrecognized role string as no-role (fail closed, logged).
  2. apps/web/src/lib/init/api.ts + init/server.ts — the wiring. The resolver is registered from two seams because the server-action module graph never imports the Hono app. Confirm initRoleResolver is genuinely idempotent (it assigns a singleton).
  3. packages/api/src/middleware/auth/resolve.tsnot in this diff, but now load-bearing. canAccessProjectAsUser() (lines 70-90) is the actual access-control function: admin/owner passes unconditionally, everyone else needs a ProjectAccess binding, suspended/no-role is denied with bindings never consulted. You cannot evaluate this PR without reading it.
  4. packages/api/src/services/org-member-service.tsupdateOrgMemberStatus / updateOrgMemberRole; where the invariants live (self-lockout prevention, owner protection, last-active-owner guard).
  5. routes/org/members.ts and routes/org/invitations.ts — thin routers, but read the guard-stack comments: role: "admin" plus an explicit scope === "project" rejection. A scope-confusion bug would live here.
  6. packages/api/src/services/org-invitation-service.ts (595 lines) — biggest and highest-stakes: token issuance, accept state machine, resolveInviteeUser. Spend the most time here.
  7. apps/web/src/lib/actions/org-invitations.ts — the only accept surface (no HTTP route, since a new invitee has a session but no DB user row). Re-derives the state-machine switch from describeInvitation — check it stays in sync.
  8. apps/web/src/lib/actions/resolve-user.ts — small diff, big consequence: adds activeMembershipWhere filtering and a canAccessProjectAsUser call to header-driven project-context resolution.

Low attention: packages/api/src/lib/cursor.ts (generic keyset pagination), all of team/_components/* and join/[token]/_components/* (UI that defers every decision to the API — verify that claim, then move on), validations/org.ts.

What to scrutinise

  • getUserRole — single point of failure for all authz. Fails closed correctly on missing membership, suspended status, unrecognized role. Confirm nothing queries roles directly instead of going through the resolver.
  • authenticateApiKey, org-key branch (~lines 41-91) — re-checks ROLE_HIERARCHY[role] < ROLE_HIERARCHY.admin per request, gated by CAPS.rbac. Closes "a demoted admin's key keeps working." It queries fresh each request: correct, but a performance-vs-staleness tradeoff worth naming explicitly.
  • The scope === "project" rejection on member/invitation routes — the mitigation for a leaked agent key whose owner is an org admin. Trace whether a project-scoped key omitting X-Project-Id could ever present as scope: "organization". (The project-key branch at lines 94-123 always returns scope: "project", so this looks safe — but it's exactly the class of bug that's expensive if wrong.)
  • acceptInvitation single-use claim — conditional updateMany({where:{status:"pending"}}) + count !== 1, done without a $transaction. The comment flags the tradeoff ("a post-claim failure burns the invitation"). Decide whether that's acceptable or whether this warranted a transaction; Postgres is already in use elsewhere.
  • Email-match-before-claim ordering (guard 4 before guard 6) — the session email must match the invited address before the token burns. Confirmed the web action passes session.email, not a client-supplied value; a mismatch here would be token-theft-via-email-spoofing.
  • resolveInviteeUser — the identity-conflict guard (byEmail.externalAuthId !== externalAuthId → 409, never relinks) is the anti-account-takeover check. Confirm IDENTITY_CONFLICT_ERROR's message is generic enough not to leak email existence.
  • resolveProjectContext (resolve-user.ts) — nearly every dashboard server component calls this. Its new canAccessProjectAsUser gate is what closes the "header-scoped project bypasses RBAC" hole in the web app. Confirm x-project-id truly can't be attacker-set (it's proxy-set from the URL path per the comment — verify proxy.ts can't be tricked).

Design decisions worth questioning

  • No $transaction anywhere in the accept path, acknowledged in the docstring as "zero usage in this package." For a service this concerned with races, conditional-update + re-read is defensible but should be explicitly blessed, not waved through as house style.
  • Role resolver registered twice from two init seams as a mutable singleton assigned via side-effecting imports. A future third module graph (worker, cron) silently gets null and fails everything closed with no obvious error.
  • ensureMemberDefaultProject creates a new project per invited member (slug: default-${userId}) rather than placing them in the org's existing default. Reads more like a workaround for the OSS project model than deliberate multi-user design.
  • orgMemberRoleSchema allows only admin/member, not owner — no way to transfer org ownership short of DB surgery. Confirm that's tracked.
  • Team nav is always visible regardless of role, degrading via a members-query 403. Deliberate and documented, but a plain member always sees a nav entry that 403s.

Test coverage reality

2,355 lines of new tests, concentrated entirely in the API/service layerroutes/org/members.test.ts (655), routes/org/invitations.test.ts (784), org-invitation-service.test.ts (536), org-role-resolver.test.ts (100), middleware/auth/api-key.test.ts (279). Genuinely strong: cross-org isolation, pagination edges, ordered-guard invariants, audit assertions including "audits nothing on failure."

Zero tests for ~1,500 lines of new/changed web code. Nothing touches team-content.tsx, member-row-actions.tsx, invite-dialog.tsx, pending-invitations.tsx, join-card.tsx, use-invitations.ts, use-org-members.ts, org-invitations.ts, or the changed resolve-user.ts. That last is the notable gap — resolveProjectContext gained a real authorization change on the web app's most central helper, resting entirely on manual review.

packages/api/src/lib/cursor.ts has no dedicated test file; it's covered only indirectly through the two route suites. Given it's shared and security-adjacent (cursors must not leak cross-org data), a direct test file would be reasonable — though the indirect coverage of observable behaviour is fairly thorough.


Reviewer orientation guide — produced by analysing this PR's diff and surrounding code, not the commit messages. Claims about line numbers and behaviour are worth spot-checking as you read; where it says something is untested or risky, that was verified against the tree rather than inferred.

@marcorivm
marcorivm force-pushed the open-edition/02-org-members-rbac branch from 62d04c3 to 9f1cae1 Compare August 8, 2026 18:30
@marcorivm
marcorivm merged commit 85a1511 into main Aug 8, 2026
@marcorivm
marcorivm deleted the open-edition/02-org-members-rbac branch August 8, 2026 19: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.

1 participant