PRD-002: Tenancy & Company RBAC
Author: MLT backend team | Date: 2026-08-18 | Status: Draft | Version: v0.2.0
Revision History
| Version | Date | Author | Description of Change |
|---|---|---|---|
| v0.1.0 | 2026-08-18 | MLT backend team | Split out of PRD-001 v0.3.0 — the multi-tenant model and RBAC on their own PRD (Epic #26). |
| v0.2.0 | 2026-08-18 | MLT backend team | Added 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 onboarding —
super_adminprovisions a company + its firsthr_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
CaseTeamMemberslice) — 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 Type | Definition | Baseline | Target |
|---|---|---|---|
| Primary | An operator provisions a company; its users are correctly authorized within it | none | works end-to-end |
| Guard Rail | No request reads/writes another tenant's data | — | 0 cross-tenant leaks (proven by test) |
| Guard Rail | No client can self-assign a role above what they may grant | — | 0 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 }, norole_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 parties —
employee/arguido,witness,external_counsel— not 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_adminis the only cross-tenant role, checked at the Account level. - SYS-REQ-302: Only
super_adminprovisions a company, setting its firsthr_admin. No self-service company creation; a one-company-per-user guard applies. - SYS-REQ-303: An
hr_admininvites users (a list of distinct email + CompanyRole); the server validates each grant. Onlysuper_adminmay granthr_admin. - SYS-REQ-304: A company has one
hr_adminby default; additional ones aresuper_admin-only (not a hard cap). Managing thehr_adminrole (grant/change/remove) issuper_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_adminspans tenants), the deferred switcher stays deferred. - Canonical mapping target for the legacy
viewerrole (likely dropped). - Does an
hr_adminwho leaves strand a company (onlysuper_adminmints 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
skippedresult; 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_admingrantinghr_admin), that row is rejected (403 / per-rowrejected) 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_adminof 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_admincalls 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_adminand anownerEmailthat belongs to no company, When they POST a company with a uniquename/slug, Then aCompanyand its firstMembership(rolehr_admin, server-assigned) are created and201 { companyId }is returned. - Given a non-
super_admincaller, When they call the provision endpoint, Then it returns 403 and creates nothing. - Given a
slugalready in use, When provisioning, Then it returns 409.
10.2 Invite users (Task #24)
- Given an
hr_adminacting in their company, When they POST a list of distinct(email, role)pairs, Then each new invitee gets aMembershipand an activation credential, and the response reports per-emailinvited | skipped | rejected. - Given an
hr_adminincludingrole = hr_adminin the list, When the request is processed, Then that row is rejected 403 (onlysuper_admingrantshr_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_adminmember'sCompanyRoleor remove that member, Then it succeeds and takes effect within the cache TTL. - Given an
hr_admin, When they attempt to grant/change/remove thehr_adminrole, Then it returns 403 (that issuper_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_idthan 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_idand is filtered by the resolved active company. Writes stampcompany_idfrom 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_adminis Account-level, checked independently of any Membership — not a value in thecompany_membershipstable (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
Caseaggregate 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.