---
path: /roles
surface: tenant-admin
domain: identity-access
auth: signed-in + tenant:admin (getTenantServerSession)
source: apps/oshun/tenant-admin/src/app/roles/page.tsx
status: drafted
last_walked: '—'
---

# Roles and permissions

## Purpose

Read-only governance view of tenant role customization: shows a tenant role
template diffed against its canonical base (risk score, reviewer-signoff
requirement), dry-run authorization results, and the state of a sample role
assignment after an approval transition. Backs the `roles` workspace
(`governance` group) in `libs/oshun/navigation/src/tenant-ia.ts`.

## Entry points

- Shell sidebar (`TenantSidebar`) — **"Roles"** (`shortLabel`) under the
  **Governance** group, link to `/roles` (`data-workspace-id="roles"`)
- Workspace grid on `/` (home) — list item `data-workspace-id="roles"`
- Direct URL `/roles` (bookmarkable; middleware-gated)

## Layout regions

Inside `TenantShell`:

- **Header**: `TenantHeader` shows `tenant · <tenantId>` › **"Roles and
  permissions"**
- **Main**:
  `<section data-testid="tenant-roles-panel" data-tenant-id={tenantId}>`
  - `<h1>Roles and permissions</h1>` + subtitle paragraph
  - `.summaryGrid` — four `.summaryItem` tiles: **Risk score**
    (`diff.riskScore`), **Reviewer signoff** ("Required" / "No"), **Dry-run
    matches** (`matched/total`), **Assignment** (`approval.assignment.state` or
    "blocked")
  - `.table` — capability diff table: columns **Capability** (`code`),
    **Canonical** ("allow"/"deny"), **Tenant** ("allow"/"deny"), **Delta**

## States

- [ ] **Loading** — N/A; server component computes the diff/dry-run in one pass
- [ ] **Empty** — N/A; canonical role defines 3 capabilities so `diff.entries`
      is always non-empty
- [ ] **Populated (short)** — capability rows for `audit.read`, `members.write`,
      `policy.attest` render; summary tiles reflect
      `diffTemplateAgainstCanonical` and `runDryRun`
- [ ] **Populated (long)** — N/A; fixture capability set is fixed
- [ ] **Error (recoverable)** — N/A; no runtime query
- [ ] **Error (unrecoverable)** — no session →
      `redirect('/unauthorized?reason=missing-session&returnTo=/roles')`
- [ ] **Offline** — N/A for data (SSR-rendered)
- [ ] **Gated** — `getTenantServerSession()` null → redirect to `/unauthorized`;
      sidebar **Locked** when `canEnterTenantWorkspace` is false
- [ ] **Standalone PWA** — verify; no manifest/SW confirmed in source

## Interactions

The `<main>` content is a static report — **no buttons, inputs, approve/deny
controls, or template editor**. All interactivity is shell chrome:

- [ ] **Skip to main content** (link, `TenantShell`) → anchors `#tenant-main`
- [ ] **Sidebar workspace links** (links, `TenantSidebar`) → navigate; current
      `aria-current="page"`; **Locked** when gated
- [ ] **Capability diff rows** — non-interactive; capability id as `code`,
      canonical/tenant as allow/deny text, `delta` value

## Data & contracts

- **Reads**: no BFF/network read. The page composes pure functions from
  `@oshun/tenant-console` on in-page fixtures:
  - `diffTemplateAgainstCanonical({ canonicalRole: CanonicalRole, template: TenantRoleTemplate })`
    → `diff.riskScore`, `diff.requiresReviewerSignoff`, `diff.entries`
  - `runDryRun({ effective, actions })` → array; **Dry-run matches** =
    `filter(r.matched).length / length`
  - `transitionAssignment({ assignment: RoleAssignment, to: 'approved', actorId, nowUnixSeconds })`
    → `approval.ok` / `approval.assignment.state`
  - `actorId` is the signed-in `session.payload.userId`;
    `NOW = Date.UTC(2026, 4, 11, 15)` (seconds)
- **Writes**: _None._ (`transitionAssignment` is a pure computation over a
  fixture, not a persisted write)
- **Realtime**: _None._
- **Caching**: SSR (server component); deterministic per `tenantId`/`userId`
- **Auth/role check**: `getTenantServerSession()` in page; middleware gate

Contracts: `CanonicalRole`, `RoleAssignment`, `TenantRoleTemplate` from
`@oshun/tenant-console` (`libs/oshun/tenant-console`).

## Cross-references

- Workspace definition: `libs/oshun/navigation/src/tenant-ia.ts` (`roles` —
  group `governance`, `requiredScopes: ['tenant:admin:*']`,
  `iconToken: 'badge-check'`)
- Shell: `TenantShell.tsx`, `TenantSidebar.tsx`, `TenantHeader.tsx`
- Sibling routes: [members.md](members.md) (members hold role assignments),
  [policy.md](policy.md), [integrations.md](integrations.md),
  [notifications.md](notifications.md), [status.md](status.md)
- Conventions:
  [../WALKTHROUGH/00-conventions.md](../WALKTHROUGH/00-conventions.md)

## Open questions / known gaps

- [ ] No live roles BFF route is wired — page renders a static
      canonical/template diff fixture, not the tenant's real role catalog.
- [ ] Despite the workspace `primaryJob` describing signoff and assignment
      management, no approve/deny/assign controls exist (read-only). Verify
      where the interactive flow is intended to live.
- [ ] `transitionAssignment` runs at render with a fixed `'approved'` target;
      the surfaced **Assignment** tile is illustrative, not a persisted
      decision.
