/** * cli:extract-doc — access-roles.ts * * The « Accès & rôles » join behind the Section 2 role table of a user doc. * * Truth model (deliberate, user-decided): THE CODE IS THE SOURCE OF TRUTH. * The join keys are the permissions actually enforced by the controller * (`[RequirePermission]`, already extracted into apiEndpoints). The committed * core-seed state (`.smartstack/core-seed/.state.json` — the materialized * role→permission matrix that seeds the database) contributes the roles; the * BA `rbac.md` only ENRICHES matched rows with actor labels + portée. A * state/BA grant that matches no code permission is EXCLUDED from the doc and * surfaced as a drift warning — declarative rights are never published. * * Everything here is read-only and never throws: a project without state/BA * degrades to `source: 'none'` and the doc keeps its plain permissions-list * fallback (the historical behaviour). */ import path from 'node:path' import { readdirSync } from 'node:fs' import { readText, directoryExists } from '../../../lib/fs.js' import { PERMISSION_ACTIONS } from '../../../lib/permission-actions.js' import { actorMatchesRole, loadModuleRbacRows, resolveBaModuleDir, type RbacRow, } from '../../../lib/ba-rbac-rows.js' import { parseCoreSeedState, CORE_SEED_STATE_DIR, type CoreSeedState, } from '../../../development/backend/core-seed/cli/scaffold-core-seed/state.js' import type { AccessRoleRow, AccessRolesReport } from './types.js' /** The BA tree root, relative to the project root. */ const BA_ROOT_DIR = '.smartstack/ba' /** * Actions that are NEVER endpoint-enforced, so their grants legitimately match * no code permission and must not raise drift warnings: * - `access` — the menu-visibility lock, checked at navigation level; * - the `read.all` scope tier — a row-level data-scope widener, not a * `[RequirePermission]` attribute. */ const NON_ENDPOINT_ACTIONS = new Set(['access', 'read.all']) /** Strip a leading app segment (any candidate, case-insensitive) + lowercase. PURE. */ export function normalizePermissionPath(permPath: string, appCodes: string[]): string { const lower = permPath.trim().toLowerCase() const firstDot = lower.indexOf('.') if (firstDot <= 0) return lower const first = lower.slice(0, firstDot) return appCodes.some((a) => a.toLowerCase() === first) ? lower.slice(firstDot + 1) : lower } /** The action (or `read.all` scope tier) suffix of a permission path. */ function actionOf(normalizedPath: string): string { const parts = normalizedPath.split('.') const last = parts[parts.length - 1] if (last === 'all' && parts.length >= 2 && parts[parts.length - 2] === 'read') return 'read.all' return last } /** The path minus its action suffix — the node prefix the grant bears on. */ function prefixOf(normalizedPath: string): string { const action = actionOf(normalizedPath) return normalizedPath.slice(0, normalizedPath.length - action.length - 1) } /** Canonical action ordering: PERMISSION_ACTIONS order (read.all rides with read), unknowns after, alphabetical. */ function sortActions(actions: string[]): string[] { const rank = (a: string): number => { const base = a === 'read.all' ? 'read' : a const i = (PERMISSION_ACTIONS as readonly string[]).indexOf(base) return i < 0 ? PERMISSION_ACTIONS.length : a === 'read.all' ? i + 0.5 : i } return [...actions].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)) } // actorMatchesRole moved to lib/ba-rbac-rows.ts (shared with // create-rbac/derive-rbac-grants) — imported above, same semantics. /** * The module code the extracted permissions bear on — the majority FIRST * segment of the normalized (app-stripped) code permissions. PURE. Drives the * `.smartstack/ba///rbac.md` lookup; null when nothing usable. */ export function deriveModuleCode(codePermissions: string[], appCodes: string[]): string | null { const counts = new Map() for (const p of codePermissions) { const normalized = normalizePermissionPath(p, appCodes) const first = normalized.split('.')[0] if (!first || first === normalized) continue counts.set(first, (counts.get(first) ?? 0) + 1) } let best: string | null = null let bestCount = 0 for (const [code, count] of [...counts].sort(([a], [b]) => a.localeCompare(b))) { if (count > bestCount) { best = code bestCount = count } } return best } export interface BuildAccessRolesInput { /** Raw apiEndpoints[].permission values (may be app-qualified; empties/duplicates tolerated). */ codePermissions: string[] /** App-code candidates to strip during normalization (state.application, navRoute[0], input.application). */ appCodes: string[] /** Parsed core-seed state, or null when absent. */ state: Pick | null /** Parsed rbac.md human rows, or null when the module has none. */ rbacRows: RbacRow[] | null } /** * The deterministic join. PURE — unit-tested. See the file header for the * truth model; drift (a grant on a node the controller covers but with no * matching endpoint permission) lands in `warnings`, never in `rows`. */ export function buildAccessRoles(input: BuildAccessRolesInput): AccessRolesReport { const { state, rbacRows } = input const warnings: string[] = [] const codePermissions = [ ...new Set( input.codePermissions .map((p) => normalizePermissionPath(p, input.appCodes)) .filter((p) => p !== '' && !p.endsWith('.unknown')), ), ].sort() if (codePermissions.length === 0) { return { source: 'none', codePermissions: [], rows: [], unmappedCodePermissions: [], warnings: [ 'No code-extracted permission to join on (controller missing or unresolved) — the role table cannot be built; keep the plain access fallback.', ], } } if (!state && (!rbacRows || rbacRows.length === 0)) { return { source: 'none', codePermissions, rows: [], unmappedCodePermissions: [], warnings: [] } } const codeSet = new Set(codePermissions) // Drift is only meaningful on the nodes THIS controller covers: the state/BA // matrix spans the whole module while the code permissions come from one // section's endpoints. const codePrefixes = new Set(codePermissions.map(prefixOf)) const inScope = (normalized: string): boolean => codePrefixes.has(prefixOf(normalized)) && !NON_ENDPOINT_ACTIONS.has(actionOf(normalized)) // ── rbac.md enrichment index: normalized path → rows granting it ───────── const rbacByPath = new Map() for (const row of rbacRows ?? []) { const normalized = normalizePermissionPath(row.path, input.appCodes) const bucket = rbacByPath.get(normalized) if (bucket) bucket.push(row) else rbacByPath.set(normalized, [row]) } const rows: AccessRoleRow[] = [] const driftPaths = new Set() const mappedPaths = new Set() const finishRow = ( role: string, roleCode: string | null, actions: string[], porteeByAction: Record, ): AccessRoleRow => { const sorted = sortActions(actions) const portees = new Set(Object.values(porteeByAction)) return { role, roleCode, actions: sorted, portee: portees.size === 1 ? [...portees][0] : null, porteeByAction: Object.keys(porteeByAction).length > 0 ? porteeByAction : null, } } if (state) { const unmatchedActors = new Set() for (const role of state.roles) { const actions: string[] = [] const porteeByAction: Record = {} for (const grant of state.rolePermissions) { if (grant.roleCode !== role.code) continue const normalized = normalizePermissionPath(grant.permissionPath, input.appCodes) if (!codeSet.has(normalized)) { if (inScope(normalized)) driftPaths.add(normalized) continue } const action = actionOf(normalized) mappedPaths.add(normalized) if (!actions.includes(action)) actions.push(action) const rbacMatches = (rbacByPath.get(normalized) ?? []).filter((r) => actorMatchesRole(r, role)) if (rbacMatches.length > 0 && !(action in porteeByAction)) { porteeByAction[action] = rbacMatches[0].portee } } if (actions.length === 0) continue rows.push(finishRow(role.name || role.code, role.code, actions, porteeByAction)) } // rbac.md actors granting an in-code permission but matching NO seeded role: // their portée could not be attached anywhere — surface, never guess. for (const [normalized, bucket] of rbacByPath) { if (!codeSet.has(normalized)) { if (inScope(normalized)) driftPaths.add(normalized) continue } for (const row of bucket) { if (!state.roles.some((role) => actorMatchesRole(row, role))) { unmatchedActors.add(row.actorLabel ? `${row.actorCode} (${row.actorLabel})` : row.actorCode) } } } for (const actor of [...unmatchedActors].sort()) { warnings.push( `rbac.md actor ${actor} matches no seeded role (state.roles) — its portée could not be attached; align the actor label with the seeded role name.`, ) } } else { // rbac.md only — DECLARATIVE rights, still keyed on the code permissions. const byActor = new Map }>() for (const [normalized, bucket] of rbacByPath) { if (!codeSet.has(normalized)) { if (inScope(normalized)) driftPaths.add(normalized) continue } const action = actionOf(normalized) mappedPaths.add(normalized) for (const row of bucket) { let entry = byActor.get(row.actorCode) if (!entry) { entry = { label: row.actorLabel ?? row.actorCode, actions: [], porteeByAction: {} } byActor.set(row.actorCode, entry) } if (!entry.actions.includes(action)) entry.actions.push(action) if (!(action in entry.porteeByAction)) entry.porteeByAction[action] = row.portee } } for (const entry of byActor.values()) { rows.push(finishRow(entry.label, null, entry.actions, entry.porteeByAction)) } warnings.push( 'Droits déclaratifs (rbac.md) — non vérifiés contre le seed (.smartstack/core-seed absent). The doc must surface this caveat (access.unverified).', ) } rows.sort((a, b) => a.role.localeCompare(b.role)) for (const p of [...driftPaths].sort()) { warnings.push( `Grant on \`${p}\` (state/rbac.md) matches NO code-enforced permission on this page's endpoints — EXCLUDED from the doc (drift: declared but not implemented).`, ) } const unmappedCodePermissions = codePermissions.filter((p) => !mappedPaths.has(p)) for (const p of unmappedCodePermissions) { warnings.push( `Code permission \`${p}\` is enforced by an endpoint but held by NO ${state ? 'seeded role' : 'rbac.md actor'} — render it as a caveat, never drop it.`, ) } return { source: state ? (rbacRows && rbacRows.length > 0 ? 'state+ba' : 'state') : 'ba', codePermissions, rows, unmappedCodePermissions, warnings, } } /** * Locate + parse the two enrichment sources under the project root (read-only, * NEVER throws — absence yields nulls plus a note in `warnings`): * - `.smartstack/core-seed/*.state.json`, matched on the parsed * `application` field against `appCode` (case-insensitive); when no * appCode candidate is known and exactly ONE state file exists, it is * used (single-app project) with a note. * - `.smartstack/ba///rbac.md`, resolved case-insensitively. */ export async function loadAccessSources( projectRoot: string, appCodes: string[], moduleCode: string | null, warnings: string[], ): Promise<{ state: CoreSeedState | null; rbacRows: RbacRow[] | null }> { let state: CoreSeedState | null = null const stateDir = path.join(projectRoot, CORE_SEED_STATE_DIR) if (await directoryExists(stateDir)) { let stateFiles: string[] = [] try { stateFiles = readdirSync(stateDir).filter((f) => f.endsWith('.state.json')) } catch { stateFiles = [] } const parsed: CoreSeedState[] = [] for (const f of stateFiles) { try { const s = parseCoreSeedState(await readText(path.join(stateDir, f))) if (s) parsed.push(s) } catch { // unreadable file — skip (never throw) } } const lowerApps = appCodes.map((a) => a.toLowerCase()) state = parsed.find((s) => lowerApps.includes(s.application.toLowerCase())) ?? (lowerApps.length === 0 && parsed.length === 1 ? parsed[0] : null) if (!state && parsed.length > 0) { warnings.push( `core-seed states found (${parsed.map((s) => s.application).join(', ')}) but none matches the app code ` + `(${appCodes.join(', ') || 'unknown'}) — roles not resolved from the seed.`, ) } } else { warnings.push(`No ${CORE_SEED_STATE_DIR}/ directory — seeded roles unavailable for the Accès & rôles table.`) } let rbacRows: RbacRow[] | null = null const baRoot = path.join(projectRoot, BA_ROOT_DIR) if ((await directoryExists(baRoot)) && moduleCode) { const appCandidates = [...appCodes, ...(state ? [state.application] : [])] for (const app of appCandidates) { const dir = resolveBaModuleDir(baRoot, app, moduleCode) if (dir) { const loaded = loadModuleRbacRows(baRoot, dir.app, dir.module) if (loaded.exists) rbacRows = loaded.rows break } } if (rbacRows === null) { warnings.push( `No BA rbac.md resolved under ${BA_ROOT_DIR}/ for module "${moduleCode}" — portée/actor labels unavailable.`, ) } } else if (moduleCode) { warnings.push(`No ${BA_ROOT_DIR}/ tree — BA portée/actor labels unavailable for the Accès & rôles table.`) } return { state, rbacRows } }