Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
84c3183
user central rbac first draft
AnuMonachan Jun 4, 2026
8ddd31c
review changes and pass org roles
AnuMonachan Jun 10, 2026
f3ad943
Merge branch 'main' into ft/user-central-rbac
AnuMonachan Jun 10, 2026
739847d
format fix
AnuMonachan Jun 10, 2026
99ef62b
added db migration files
AnuMonachan Jun 10, 2026
53c98cb
updated db migration files as per review
AnuMonachan Jun 11, 2026
e05b9b9
code review fixes
AnuMonachan Jun 11, 2026
7538074
policy fixes and other code review issues
AnuMonachan Jun 11, 2026
e7f1f72
code review fixes, issues found by code rabbit
AnuMonachan Jun 11, 2026
10df565
minor bug fixes
AnuMonachan Jun 11, 2026
3330273
Merge branch 'main' into ft/user-central-rbac
kaseywright Jun 14, 2026
27719f5
updated code in order to incorporate org switcher ui
AnuMonachan Jun 24, 2026
0ed7be3
error fix
AnuMonachan Jun 25, 2026
5781254
Merge branch 'main' into ft/user-central-rbac
AnuMonachan Jul 13, 2026
1e6e9ac
format fix
AnuMonachan Jul 13, 2026
2a290f3
error fixes
AnuMonachan Jul 14, 2026
73a1d5d
ft: add role assignment support to project user addition and implemen…
Joel-Joseph-George Jul 17, 2026
609dc1c
ft: add PATCH /projects/:projectId/users/:userId endpoint to update m…
Joel-Joseph-George Jul 21, 2026
afd7987
docs(rbac): add org membership and solo workflow design spec
Jul 23, 2026
2d87c35
fix(auth): restore grant.projectId === null guard for org-scoped auth…
Joel-Joseph-George Jul 24, 2026
b6a9094
feat: implement organization user management and project role assignm…
Joel-Joseph-George Jul 24, 2026
8b89794
fix: formatting code base
Joel-Joseph-George Jul 24, 2026
1d9bf1a
feat: fixes for valid code rabbit concerns
Joel-Joseph-George Jul 24, 2026
438820f
refactor: clean up unused fields and improve permission validation
Joel-Joseph-George Jul 27, 2026
8ba2ef6
fix: improve formatting and consistency in user roles repository
Joel-Joseph-George Jul 27, 2026
38f4c0a
refactor: replace roleName with roleId in user invitation flows and a…
Joel-Joseph-George Jul 27, 2026
337ae2b
Ft: Coderabbit suggestion
Joel-Joseph-George Jul 27, 2026
24510b2
Merge branch 'main' into ft/user-central-rbac
Joel-Joseph-George Jul 28, 2026
cdef4b9
ft: Migration file changes regarding the user centric changes
Joel-Joseph-George Jul 30, 2026
88d4b17
feat: suggestions in migrations from code rabbit
Joel-Joseph-George Jul 30, 2026
2cff303
ft: enforce project access control in AI tools and suggestions via ne…
Joel-Joseph-George Jul 31, 2026
3b32e6e
feat: enhance user role management by introducing roleName parameter …
Joel-Joseph-George Aug 11, 2026
edbdb73
ft:Code rabbit suggestions
Joel-Joseph-George Aug 11, 2026
b13b404
chore: renumber branch migrations 0015-0018 → 0019-0022 ahead of main…
Joel-Joseph-George Aug 11, 2026
c2fb73f
chore: merge origin/main — resolve migration numbering and code confl…
Joel-Joseph-George Aug 11, 2026
697c06c
fix(rbac): update verse-audio middleware to user-centric grants shape
Joel-Joseph-George Aug 11, 2026
2abdb64
feat: implement verse audio files updated with rbac changes
Joel-Joseph-George Aug 11, 2026
0eb069f
fix(rbac): improve validation for projectUnitId and bibleTextId in ve…
Joel-Joseph-George Aug 11, 2026
164e755
chore: update migration timestamps in journal metadata (#265)
kaseywright Aug 12, 2026
421777a
feat: add migration script for project manager roles
Joel-Joseph-George Aug 13, 2026
72cb77a
feat: enhance user management with createdBy tracking in project user…
Joel-Joseph-George Aug 13, 2026
cfda307
fix:format
Joel-Joseph-George Aug 13, 2026
198325c
fix:format error
Joel-Joseph-George Aug 13, 2026
9f2f4d9
feat: include created_by field in user_roles and project_roles migrat…
Joel-Joseph-George Aug 17, 2026
206a352
feat: implement temporary bypass for Project Managers to create projects
Joel-Joseph-George Aug 20, 2026
a21a5b4
Merge remote-tracking branch 'origin/main' into ft/user-central-rbac
Aug 20, 2026
4d29c66
Refactor translation-resources.tests.ts for new rbac
Aug 20, 2026
6d2bfdd
Merge branch 'main' into ft/user-central-rbac
kaseywright Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Org Membership & Solo-Workflow Extension — Design Spec

**Date:** 2026-07-02
**Status:** Approved for implementation planning
**Source:** `2026-06-02-user-central-tenant-rbac-design.md`, `2026-06-02-user-central-tenant-rbac.md`, [[2026-07-02 User Central tenant]] (meeting + Claude analysis)

## Relationship to the existing RBAC design

This spec **amends** the 2026-06-02 RBAC design; it does not replace it. The grant table (`user_roles`), the `authorize()` engine, and the "org membership is emergent" principle are all unchanged. This document closes a gap the 7/2 meeting surfaced live: the original design defines org membership as "≥1 `user_roles` row with this `org_id`," but every grant also requires a non-null `role_id` — so there was no way to represent "invited into an org, not yet given any role." It also defines the removal/cascade behavior that was left unspecified, and identifies (without fully designing) the extension point for a future solo-user workflow.

## Problem

Two gaps surfaced during the 2026-07-02 design walkthrough:

1. **Bare org membership.** An Org Manager needs to invite a person into an org before that person has any project to be assigned to. Roles, by design, are scoped to a project (or are the org-wide `Org Manager` role) — there is no role that means "just a member." Without one, either the schema needs a nullable `role_id` (which breaks the "grant = role + permission set" invariant everywhere else) or invited-but-unassigned users can't be represented at all.
2. **Solo workflow.** A user should be able to register and start a project for their own church without an org or team pre-existing. The application has deep assumptions about separate drafter/checker/reviewer roles that make a _fully_ solo translation pipeline out of scope for now — but registration and project creation should not be architecturally blocked on "someone else set up an org for you first."

## Goals

- Represent org membership without requiring a functional role, using the existing grant model — no new tables, no nullable `role_id`.
- Guarantee that a user's org membership survives changes to their project-level grants (being removed from a project must never silently evict them from the org).
- Provide two distinct, consistently-behaved removal operations: remove-from-project and remove-from-org.
- Leave a clear, low-risk extension point for self-service org/project creation, without committing to a full solo-translation-pipeline design now.

## Non-Goals

- Designing the solo translator/checker workflow (single person performing drafting, peer-check, and review alone). Deferred; see Open Items.
- Changing the `authorize()` engine, the `Grant`/`AuthScope` types, or the unique constraint on `user_roles`. All unchanged from the 2026-06-02 design.
- Revisiting the full Org Manager permission set narrowing flagged in the 7/2 meeting analysis — that is a separate, already-tracked follow-up to the RBAC seed (Task 16 of the implementation plan). This spec assumes that narrower Org Manager permission set is applied but does not redefine it here.

## Core Insight — Membership Is a Grant With No Permissions

The existing model already treats permissions as a property of the _grant_, not the _role name_. Extending that one step further: a role can exist purely to mark presence, carrying zero permissions. That role is a first-class `roles` row like any other — `authorize()` needs no special case, no null-checks, and no second membership table.

## Data Model

### New seeded role: `Org Member`

Added to `ROLES` and seeded with **no entries in `role_permissions`**. A grant of this role never satisfies any `authorize()` check — it exists only so a `user_roles` row can exist.

```ts
export const ROLES = {
SUPER_ADMIN: 'SuperAdmin',
ORG_MANAGER: 'Org Manager',
ORG_MEMBER: 'Org Member', // new
PROJECT_MANAGER: 'Project Manager',
PROJECT_TRANSLATOR: 'Project Translator',
PROJECT_OBSERVER: 'Project Observer',
} as const;
```

Org Owner is removed.

(`Org Owner` is omitted here per the 7/2 decision to collapse it into `Org Manager` — tracked as part of the Task 16 permission-map follow-up, not redefined in this doc.)

### The anchor row

Inviting a user into an org (an Org Manager action) creates exactly one grant:

```text
(user, org_id, project_id: null, role: Org Member)
```

This is the **anchor row**. Its only job is to make the user emergent-a-member of the org — i.e., visible in that org's user list, invitable to any project within it.

### Anchor rows are never updated — only added alongside

When a user is later given a real role (a project-scoped grant, or promotion to `Org Manager`), a **new grant row is inserted**. The anchor row is left untouched.

```text
(user, org, null, OrgMember) <- anchor, created at invite time, never modified
(user, org, project, ProjectManager) <- added when granted; can be removed independently
```

This is unique-constraint-safe: the anchor and any work grant differ in `project_id` and/or `role_id`, so they never collide under `uq_user_role_grant`.

**Why this matters:** if a project grant were instead created by _mutating_ the anchor row, then later revoking that grant would delete the user's only `user_roles` row in that org — and since org membership is emergent ("≥1 row with this `org_id`"), the user would silently fall out of the org. Keeping the anchor separate and permanent means project-level grants can be freely added and removed without ever affecting org membership.

### Repository changes

- `findGrantsByUserId` — unchanged; `Org Member` grants simply contribute an empty permission set and are harmless no-ops in `collectPermissions`.
- New: `inviteUserToOrg(userId, orgId, createdBy)` in `user-roles.service.ts` — thin wrapper that calls `grantRole({ userId, orgId, projectId: null, roleId: getRoleId(ROLES.ORG_MEMBER), createdBy })` with `onConflictDoNothing` (idempotent — inviting an already-member user is a no-op, not an error).
- The org's "invitable users" / user-list query is unchanged: any user with a `user_roles` row where `org_id = O` (anchor or otherwise) is a member of O.

## Removal — Two Operations, Consistent Behavior, Auto-Unassign

Both removal operations **unassign the user's active work first, then delete grants** — they do not block on active assignments. The UI is responsible for warning the actor clearly before the action is taken (see UI Behavior below).

### Remove from project

**Trigger:** PM/Org Manager action on a project's user table.

**Steps (single transaction):**

1. Clear the user as `assignedUserId` (drafter) and `peerCheckerId` on every `chapter_assignments` row in that project where they're currently assigned.
2. Delete the project-scoped `user_roles` grant(s) for that user in that project.

**Result:** the user loses access to the project; any chapter assignments they held are now unassigned (blank drafter/peer-checker), surfaced by the existing drafter/peer-checker sort & filter (already planned per the 7/2 ticket list) so the PM can quickly find and reassign them. Org membership (the anchor row) is untouched.

### Remove from org

**Trigger:** Org Manager action on the org's users page.

**Steps (single transaction):**

1. Across **every project in the org**, clear the user as `assignedUserId`/`peerCheckerId` wherever they're currently assigned (same unassign logic as project removal, applied org-wide).
2. Delete **every** `user_roles` grant that user holds with this `org_id` — the anchor row and every project-scoped or org-scoped grant.

**Result:** the user is fully disassociated from the org (per the 7/2 decision: "it just disassociates them from the org... not gonna delete any of their data or their account"). Their account, and any grants in _other_ orgs, are unaffected.

Both operations share one implementation: `unassignActiveWork(userId, projectIds[])` + a grant-deletion step scoped to either one project or the whole org. Remove-from-org is remove-from-project applied to the full set of the org's project IDs, plus the anchor.

### UI behavior (both operations)

Before either removal executes, the UI must show a confirmation dialog stating plainly what will happen — explicitly naming any assignments that will be cleared (e.g., "Jamie is currently the drafter on 3 chapters and peer-checker on 1. Removing them will unassign this work. Continue?"). No silent unassignment. This mirrors the existing confirmation-step pattern already used for other destructive actions in the app.

## Solo-Workflow Extension Point (Deferred)

Registration does **not** auto-create an org for a new user. A newly registered user exists with zero `user_roles` rows.

The extension point is at **project creation**: when a user with no existing org hits "create project," the service layer provisions a personal org and grants that user `Org Manager` **and** `Project Manager` on it, in the same transaction that creates the project (reusing the existing auto-assign-creator-as-PM logic from Task 11 of the RBAC plan — no new grant-issuing code path is needed, just a preceding "does this user have zero orgs; if so, create one first" check).

This makes a solo user structurally identical to a small team where one person happens to hold every relevant role — no new permission concepts, no schema changes beyond what's already specified. It deliberately does **not** attempt to solve the separate drafter/peer-checker/reviewer assumptions baked into the chapter-assignment workflow; a solo user would need to hold `Project Translator` as well and interact with a pipeline still modeled around distinct people. That gap is called out explicitly below rather than papered over.

## Testing

- **Unit:** `Org Member` grants contribute no permissions to `collectPermissions` (regression test against the existing `authorize.test.ts` suite — a user with only an `Org Member` grant is denied every permission check).
- **Unit:** anchor-row survival — grant a project-scoped role, then revoke it; assert the anchor row (and therefore emergent org membership) still exists.
- **Integration:** remove-from-project clears `assignedUserId`/`peerCheckerId` on affected chapter assignments and deletes only the project-scoped grant; org membership (anchor + other project grants) is unaffected.
- **Integration:** remove-from-org clears assignments across every project in the org and deletes all grants including the anchor; grants in other orgs are unaffected.
- **Integration:** inviting an already-member user (`inviteUserToOrg` called twice) is idempotent — no duplicate row, no error.

## Open Items

- **Org Manager permission narrowing** (view-only on projects, no content edit) is assumed but not redefined here — tracked against Task 16 of the existing RBAC implementation plan.
- **Solo translation pipeline** (single person performing drafting, peer-check, and consultant review without triggering workflow states designed for separate people) is explicitly deferred. The schema does not block it, but the chapter-assignment status machine and UI will need their own design pass before a fully solo project is usable end-to-end.
- **UI copy/flow for the two removal confirmations** (exact wording, how assignment counts are surfaced) is a follow-up UI ticket, not specified here.
102 changes: 102 additions & 0 deletions docs/temp-pm-project-create-bypass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Temp: Project Manager Can Create Projects

## Why this exists

QA needs to be able to create and test projects before the proper Org Manager
workflow is in place. The correct long-term design is that only Org Managers
(org-scoped role) can create projects. Project Managers are project-scoped and
should not be able to create projects.

This bypass was added deliberately as a short-lived workaround.

---

## What was changed

### fluent-api

#### 1. `src/db/seeds/rbac.ts`

`PROJECT_CREATE` was kept in the Project Manager permission list. This is what
puts the permission into the PM's grant object at runtime. Without it, the
route bypass below would also fail.

```ts
// Keep until revert
{ roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.PROJECT_CREATE },
```

#### 2. `src/domains/projects/projects.route.ts` — `POST /projects` middleware

The normal `requirePermission(PROJECT_CREATE, orgFromBody)` check requires an
org-wide grant (`projectId = null`). Project Managers are always project-pinned,
so they would fail this check.

A TEMP block was added that also allows any grant carrying `project:create`
within the matching org (regardless of `projectId`). Search for `// TEMP:` in
the file to find the exact block.

---

## How the permission system works (context for newcomers)

When a user is assigned a role, a row is written to `user_roles`:

| userId | orgId | projectId | roleId |
| ------ | ----- | --------- | --------------- | ----------------------------------- |
| 5 | 2 | null | org_manager | ← org-wide, CAN create projects |
| 5 | 2 | 9 | project_manager | ← project-pinned, CANNOT (normally) |

At login, `findGrantsByUserId()` loads all rows and groups them into `Grant`
objects: `{ orgId, projectId, permissions: Set<...> }`.

When `project:create` is checked for `POST /projects`, the scope is
`{ orgId: X }` (no projectId). The core `authorize()` function requires
`grant.projectId === null` for org-level checks. A Project Manager's grant has
`projectId = 9`, so it fails the scope check even if `project:create` is in
their permission set.

The TEMP bypass skips this scope check, allowing PM through. Once Org Managers
exist in the system and QA accounts are set up properly, this bypass is no
longer needed.

---

## How to revert (when Org Manager workflow lands)

Do exactly these two things — nothing else in the system needs to change.

### Step 1 — `src/db/seeds/rbac.ts`

Remove this line from the `ROLE_PERMISSION_MAP`:

```ts
{ roleName: ROLES.PROJECT_MANAGER, permissionName: PERMISSIONS.PROJECT_CREATE },
```

Re-run the RBAC seed so the DB reflects the removal.

### Step 2 — `src/domains/projects/projects.route.ts`

Find the `// TEMP:` comment block in the `POST /projects` middleware and
collapse the entire `async (c, next) => { ... }` block back to the original:

```ts
(c: any, next: any) => {
const user = c.get('user');
const hasAnyOrg = user?.grants?.some((g: any) => g.orgId !== null);
if (!hasAnyOrg) return next(); // zero-org solo path — skip permission gate
return requirePermission(PERMISSIONS.PROJECT_CREATE, orgFromBody)(c, next);
},
```

---

## Files summary

| File | What to revert |
| ---------------------------------------- | ----------------------------------------------- |
| `src/db/seeds/rbac.ts` | Remove `PROJECT_MANAGER + PROJECT_CREATE` entry |
| `src/domains/projects/projects.route.ts` | Collapse TEMP middleware back to 3-liner |

`fluent-web` does not need any changes for this revert.
8 changes: 1 addition & 7 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,7 @@ export default antfu(
typescript: true,
formatters: true,
stylistic: false,
ignores: [
'**/migrations/*',
'src/db/seeds/data/**',
'data/**',
'.superpowers/**',
'docs/proposals/**',
],
ignores: ['**/migrations/*', 'src/db/seeds/data/**', 'data/**', '.superpowers/**', 'docs/**'],
},
{
...prettier,
Expand Down
Loading
Loading