Skip to main content

PRD-002: Tenancy & Company RBAC

Author: MLT backend team | Date: 2026-08-18 | Status: Draft | Version: v0.2.0


Revision History

VersionDateAuthorDescription of Change
v0.1.02026-08-18MLT backend teamSplit out of PRD-001 v0.3.0 — the multi-tenant model and RBAC on their own PRD (Epic #26).
v0.2.02026-08-18MLT backend teamAdded refine-feature findings: edge cases, Given-When-Then acceptance criteria, technical constraints, and a ROM ballpark (backend M). Pending human review.

1. Context & Business Rationale

Builds on PRD-001: Identity & Auth (the Account/authentication layer). This PRD covers which tenant an account acts in and what it may do — the multi-tenant Company/Membership model and role-based access control. Every case-bearing feature scopes to it.

The vibecoded PoC got the trust model wrong in three ways this PRD fixes: effective role was the global maximum across all companies (privilege bleed), the active tenant was in-memory only, and roles were client-supplied on self-insert (a user could make themselves super_admin). See the autopsy: role is global-max, company context in memory, self-provision escalation.

Sourcing: the firm brief (_poc/firm-qa.md) and the 2026-08-11 client meeting (_poc/meeting-notes-2026-08-11.md).

2. Problem Statement

There is no way to associate an account with a company, assign a role, isolate tenant data, or authorize actions. Without a server-authoritative tenancy + RBAC layer, no case data can be safely stored or scoped.


3. Goals, Non-Goals, and Success Metrics

3.1 Goals

  • A server-owned Company (tenant) and Membership (Account × Company × CompanyRole) model.
  • Per-tenant authorization — permissions resolved for the active company only, never global-max.
  • Operator-provisioned onboardingsuper_admin provisions a company + its first hr_admin; others join by invite + activation. Roles never client-chosen.

3.2 Non-Goals

  • Case-team assignment & case-scoped authorization (attaching members to a case; capacity derives from CompanyRole) — a follow-up epic, blocked by the Case aggregate. Now tracked as #196 (epic) / #197 (the CaseTeamMember slice) — surfaced as a real blocker while building #58 (Nota de Culpa's legal-review gate), which needs "the assigned legal_reviewer for this case" and had nothing to resolve it against.
  • A company switcher UI — Membership is multi-company-capable, but no switcher now; a one-company-per-user guard applies at creation.
  • Authentication mechanics — PRD-001.

3.3 Success Metrics

Metric TypeDefinitionBaselineTarget
PrimaryAn operator provisions a company; its users are correctly authorized within itnoneworks end-to-end
Guard RailNo request reads/writes another tenant's data0 cross-tenant leaks (proven by test)
Guard RailNo client can self-assign a role above what they may grant0 escalation paths

4. Roles & Actors

  • super_admin — the platform operator (Miew). An Account-level capability, cross-tenant, checked independently of any Membership (NOT a CompanyRole — a per-tenant join can't hold a global role; that was the PoC's bug). Provisions companies, assigns any role incl. hr_admin, sees across tenants. Seeded out-of-band.
  • CompanyRole (per-tenant, on a Membership) = { hr_admin, case_manager, instructor_internal, instructor_external, legal_reviewer, decider } — the firm's invitable ladder; the single place a person's function lives.
  • CaseTeamMember — a relationship, not a role ({ case_id, account_id, assigned_by, assigned_at }, no role_in_case). Capacity on a case = the member's CompanyRole; the assignment just scopes which cases they're on. Separation of duties holds structurally (one CompanyRole per person). Full spec in the case-team follow-up epic.
  • Portal partiesemployee/arguido, witness, external_counselnot app roles; token-based (separate epic).

5. User Stories

  • As the operator, I want to provision a company and its first HR admin so a client can start.
  • As an HR admin, I want to invite teammates (a list of email + role) so they can work in my company.
  • As an HR admin, I want to change a member's role or remove them.
  • As any user, I want the system to show me only my company's data so tenants stay isolated.

6. System Requirements

6.1 Tenancy

  • SYS-REQ-201: A Membership = Account × Company × CompanyRole is the single source of truth for tenancy and role.
  • SYS-REQ-202: The active company is carried per request (route segment or X-Company-Id), validated against the caller's memberships; a single-membership account defaults automatically. No switcher UI for now.
  • SYS-REQ-203: Every tenant-owned entity is constrained to the active company by an EF Core global query filter; writes stamp company-id from context, never the client body.

6.2 RBAC & provisioning

  • SYS-REQ-301: Permissions are resolved from the active company's membership — never global-max. super_admin is the only cross-tenant role, checked at the Account level.
  • SYS-REQ-302: Only super_admin provisions a company, setting its first hr_admin. No self-service company creation; a one-company-per-user guard applies.
  • SYS-REQ-303: An hr_admin invites users (a list of distinct email + CompanyRole); the server validates each grant. Only super_admin may grant hr_admin.
  • SYS-REQ-304: A company has one hr_admin by default; additional ones are super_admin-only (not a hard cap). Managing the hr_admin role (grant/change/remove) is super_admin-only.

7. Workflow

super_admin provisions Company + first hr_admin ──► hr_admin invites users (email + role)
│ │
▼ ▼
(PRD-001 activation) each user activates → logs in


Every request: identity cookie → request carries company → membership resolved (cached)
→ CompanyRole → authorized + tenant-scoped (EF global filter)

8. Open Questions

  • How common are genuinely multi-company users? If rare (only super_admin spans tenants), the deferred switcher stays deferred.
  • Canonical mapping target for the legacy viewer role (likely dropped).
  • Does an hr_admin who leaves strand a company (only super_admin mints a replacement)? — acceptable under consultive onboarding, but confirm.

9. Edge Cases & Exception Handling

  • EC-001: Cross-tenant access attempt. If a request is scoped to a company the caller holds no Membership in (guessed id / stale link), the system shall return 403 and leak nothing about the company's existence; the attempt is written to the audit log.
  • EC-002: No active company resolvable. If a caller has multiple memberships and the request carries no company (no route segment / X-Company-Id), the system shall return a clear "select a company" error (409/422) rather than guessing; a caller with exactly one membership defaults to it automatically.
  • EC-003: Duplicate / already-member invite. Within one invite request, duplicate emails are de-duplicated; an email already a member of the company is skipped with a per-row skipped result; the request still succeeds for the other rows (partial success reported per-email, never all-or-nothing).
  • EC-004: Over-grant on invite. If an inviter tries to grant a role above what they may grant (e.g. an hr_admin granting hr_admin), that row is rejected (403 / per-row rejected) and no Membership is created for it.
  • EC-005: Slug collision on provision. If the company slug already exists, provisioning returns 409 and creates nothing.
  • EC-006: Last-admin protection. Removing or downgrading the last remaining hr_admin of a company is refused with a clear 409 and an explanatory message.
  • EC-007: One-company-per-user guard. If an account that already belongs to any company is used to provision another, provisioning is refused (409) — one company per user for now.
  • EC-008: Stale role after change. A role change (or removal) takes effect within the membership-resolution cache TTL, not at cookie expiry; a removed member's in-flight session stops resolving for that company (403) within the TTL.
  • EC-009: Provision authorization. If a non-super_admin calls the company-provision endpoint, it returns 403 (there is no self-service company creation).
  • EC-010: Invite email delivery failure. If the activation/invite email fails to send, the Membership is still created but flagged so the invite can be re-sent; the invitee cannot act until they activate (delivery is retried via the E10 pipeline; see EC in PRD-001 for the activation flow itself).

10. Given-When-Then Acceptance Criteria

10.1 Provision a company (Task #23)

  • Given an authenticated super_admin and an ownerEmail that belongs to no company, When they POST a company with a unique name/slug, Then a Company and its first Membership (role hr_admin, server-assigned) are created and 201 { companyId } is returned.
  • Given a non-super_admin caller, When they call the provision endpoint, Then it returns 403 and creates nothing.
  • Given a slug already in use, When provisioning, Then it returns 409.

10.2 Invite users (Task #24)

  • Given an hr_admin acting in their company, When they POST a list of distinct (email, role) pairs, Then each new invitee gets a Membership and an activation credential, and the response reports per-email invited | skipped | rejected.
  • Given an hr_admin including role = hr_admin in the list, When the request is processed, Then that row is rejected 403 (only super_admin grants hr_admin) while other rows still succeed.
  • Given an email already a member of the company, When invited again, Then that row is skipped with a clear per-row result.

Endpoint contract (machine-checked by the verify-contracts skill):

POST /companies/{companyId}/invites
req: invites[{email, role}]
200: results[{email, status}]
400 InvitesRequired
400 TooManyInvites
403 NotAuthorizedToInvite
403 NotAuthorizedToGrantHrAdmin
404 CompanyNotFound

10.3 Manage a member's role and access (Task #25)

  • Given an hr_admin, When they change a non-hr_admin member's CompanyRole or remove that member, Then it succeeds and takes effect within the cache TTL.
  • Given an hr_admin, When they attempt to grant/change/remove the hr_admin role, Then it returns 403 (that is super_admin-only).
  • Given the last remaining hr_admin, When a removal/downgrade is attempted, Then it returns 409 and the admin is preserved.
  • Given any member of a company, When they GET the members list, Then they receive the company's members and no other tenant's data.

10.4 Tenant isolation (cross-cutting)

  • Given two companies each with data, When a caller acting in company A reads any tenant-owned collection, Then only company A's rows are returned (proven by a two-company integration test).
  • Given a write whose body carries a different company_id than the active company, When it is submitted, Then the mismatched id is ignored/rejected — the row is stamped from context, never the body.

11. Technical Constraints & Dependencies

  • Depends on PRD-001 (Account, login, activation) — a Membership attaches to an Account; invites drive PRD-001 activation.
  • EF Core global query filter is the isolation mechanism; every tenant-owned entity carries company_id and is filtered by the resolved active company. Writes stamp company_id from request context.
  • Per-request membership resolution (route segment / X-Company-Id → Membership) is cached with a short TTL; role/removal changes take effect within that TTL, not at cookie expiry.
  • super_admin is Account-level, checked independently of any Membership — not a value in the company_memberships table (that was the PoC's category error).
  • Miewdiator is the mediator (not MediatR); provisioning/invite/manage emit domain events (theme E15) rather than calling side-effects inline.
  • Invite/activation emails are sent via the real IEmailSender (MailKit → Mailtrap in dev/test) from theme E15, with reliable delivery layered by the E10 email pipeline.
  • No case-team authorization until the Case aggregate exists (theme E03); the case-team relationship is a separate follow-up epic.
  • Open (pre-build): the credential-delivery mechanism for invite/activation (activation link default; magic-link / SSO / random-temp alternatives) — see Open Questions.