/** * cli:derive-rbac-grants — check.ts * * Bidirectional parity between the fresh BA derivation and the SEEDED state * (`.smartstack/core-seed/.state.json`) — the engine of audit-dev-core * **DEV-CORE-011**: * * - missing-grant: a BA-specified grant absent from the state — a role is * silently missing a right the BA specified (the audit's H1 class); * - extra-grant: a state grant that neither the human matrix, the * derived-lookups block nor the `.access` grandfathering explains — an * invented right (the H2 class; rbac.md records no ✗ cell, so "not * granted" can only be enforced as "everything else is an extra"). * * Role identity BA ↔ state goes through `actorMatchesRole` * (lib/ba-rbac-rows.ts — label⇔name / label⇔code / code⇔code, plus the * deterministic slug) so legacy projects with hand-picked role codes still * compare; an actor holding grants that matches NO seeded role is reported, * and its grants surface as missing (never silently dropped). * * Pure given loaded states — the caller does the fs. Drift is DATA (exit 0); * the verdict belongs to the audit rule. */ import { actorMatchesRole } from '../../../../lib/ba-rbac-rows.js' import type { AppFragment, CheckAppReport, CheckReport, GrantFragment } from './types.js' /** The subset of scaffold-core-seed's state file this check reads (parsed * leniently on purpose — no cross-skill runtime import; the shape is pinned * by `CoreSeedState` in scaffold-core-seed/state.ts and the drift test). */ export interface StateLike { application: string roles: { code: string; name: string }[] rolePermissions: { roleCode: string; permissionPath: string }[] } const grantKey = (g: GrantFragment): string => `${g.roleCode}|${g.permissionPath}` export function checkAgainstStates(fragments: AppFragment[], states: StateLike[]): CheckReport { const apps: CheckAppReport[] = fragments.map((fragment) => { const state = states.find((s) => s.application === fragment.appCode) if (!state) { return { appCode: fragment.appCode, stateFound: false, missingGrants: fragment.rolePermissions, extraGrants: [], unmatchedActors: [], upToDate: false, } } // BA role slug → seeded role code (identity via actorMatchesRole legs). const roleCodeMap = new Map() const unmatchedActors: string[] = [] const granting = new Set(fragment.rolePermissions.map((g) => g.roleCode)) for (const actor of fragment.actors) { const match = state.roles.find( (role) => role.code.trim().toLowerCase() === actor.code.trim().toLowerCase() || actorMatchesRole({ actorCode: actor.baCode, actorLabel: actor.name }, role), ) if (match) { roleCodeMap.set(actor.code, match.code) } else if (granting.has(actor.code)) { unmatchedActors.push(`${actor.baCode} (${actor.name})`) } } const expectedByKey = new Map() for (const g of fragment.rolePermissions) { const remapped = { roleCode: roleCodeMap.get(g.roleCode) ?? g.roleCode, permissionPath: g.permissionPath, } expectedByKey.set(grantKey(remapped), remapped) } const expected: GrantFragment[] = [...expectedByKey.values()] const expectedKeys = new Set(expected.map(grantKey)) const stateKeys = new Set(state.rolePermissions.map(grantKey)) const missingGrants = expected .filter((g) => !stateKeys.has(grantKey(g))) .sort((a, b) => a.roleCode.localeCompare(b.roleCode) || a.permissionPath.localeCompare(b.permissionPath)) const extraGrants = state.rolePermissions .filter((g) => !expectedKeys.has(grantKey(g))) .sort((a, b) => a.roleCode.localeCompare(b.roleCode) || a.permissionPath.localeCompare(b.permissionPath)) return { appCode: fragment.appCode, stateFound: true, missingGrants, extraGrants, unmatchedActors: unmatchedActors.sort(), upToDate: missingGrants.length === 0 && extraGrants.length === 0 && unmatchedActors.length === 0, } }) return { apps, upToDate: apps.every((a) => a.upToDate) } }