Date: 2026-07-10/11
Scope: docs/design/oyatie-console/DESIGN.md §4-22 and §4-23 directives.
Method: live browser exercise (Playwright against Chromium) of a freshly
migrated, real backend + real Postgres dev stack — no mocks, no stubs.
No code changes made; this is a findings register only.
- Fresh worktree
.worktrees/w4-auditsofforigin/main(7aa4b990). - Scratch database created via
psqlinside the already-runningconsole-devPostgres container (console_scratch_w4audit, port 55432) — did not touch any other lane'sconsole_devdatabase or the crash-loopingconsole-dev-app-1container (pre-existing, unrelated breakage —CONSOLE_JWT_PUBLIC_KEY_PEMconfig error in that container, left untouched). - Backend:
node scripts/dev-up.mjs bootstrapwithCONSOLE_DEV_AUTH_E2E=1,CONSOLE_POSTGRES_DB=console_scratch_w4audit,CONSOLE_DEV_HTTP_PORT=8190— realconsole-app --features dev-auth, 162/162 migrations applied,/readyzgreen. - Frontend:
viteon:5173, real dev-auth role-switch login (SUPER_ADMIN, org00000000-0000-0000-0000-0000000000a1/ KNL), real WebAuthn passkey enrollment via a CDP virtual authenticator (not skipped/mocked) when the onboarding gate required it. - Driven with Playwright (already a project devDependency) since no chrome-devtools MCP was available in this subagent's toolset; screenshots saved to the session scratchpad.
Finding 1 — CRITICAL — /policy/authorize/bulk (and /policy/authorize) is wired to a 5-action allowlist, so every fine-grained PolicyGated affordance console-wide silently disappears for every role, including SUPER_ADMIN
Where: backend/crates/platform/authz-rest/src/lib.rs (authorize_bulk,
authorize handlers) → both call simulate_policies =
console_platform_authz::cedar_pbac::authoring::simulate
(backend/crates/platform/authz/src/cedar_pbac/authoring.rs:594).
Root cause: authoring::simulate_inner hard-denies any request whose
action is not in the compile-time constant AUTHORING_ACTIONS
(authoring.rs:191):
const AUTHORING_ACTIONS: &[&str] = &[
"view", "edit", "read_field", "console:configure", "console:deploy",
];This is the no-code policy-authoring canvas's own tiny action vocabulary
(for /policy/simulate what-if runs), not a general-purpose runtime
authorization action registry. But the frontend's real, live authorization
gate — BulkPolicyGateProvider in
web/src/console/policy/PolicyGated.tsx — calls
POST /api/v1/policy/authorize/bulk with the console's actual granular
action vocabulary: ontology.schema.create, ontology.schema.edit,
ontology.instance.open, console.object_explorer.node.create,
evidence.custody.manage, governance.approval.decide,
console.workflows.workflow.publish.approve, etc. — over 100 distinct
action strings across web/src/console/*/types.ts, essentially none of
which are in the 5-item AUTHORING_ACTIONS list.
Every one of those requests comes back effect: "deny",
reason: "action \"…\" is not authorizable; deny-by-omission" — for
every subject, every org, every role, because the deny happens
before any policy/role lookup even runs. PolicyGated renders nothing on
deny (web/src/console/policy/PolicyGated.tsx:154, "deny-by-omission
render gate… absent, not disabled"), so the affected UI doesn't 403 or grey
out — it simply never appears, with no error surfaced to the user.
Repro:
- Boot the stack as above, log in as
SUPER_ADMIN(dev-auth role switch). - Open browser devtools / a request interceptor, navigate to
/ontology. - Observe the real app-issued
POST /api/v1/policy/authorize/bulkrequest; response body:{"decisions":[ {"effect":"deny","reason":"action \"ontology.schema.create\" is not authorizable; deny-by-omission", ...}, {"effect":"deny","reason":"action \"ontology.schema.approve\" is not authorizable; deny-by-omission", ...}, ... ]} - On screen: the "타입" (Type) rail shows
0개and the entire inline add-type form (새 타입 이름input +타입 추가button,OntologyManagerScreen.tsx:308-320, gated by<PolicyGated action={ONTOLOGY_MANAGER_ACTIONS.typeCreate}>) is absent from the DOM — not disabled, not present at all — for the platform's ownSUPER_ADMIN.
Impact / severity: Critical, blast radius is essentially the whole
console. This is not scratch-DB-specific and not a dev-auth artifact — the
5-action allowlist is a Rust compile-time constant on main
(1c361252, PR #432), so it is present in the production build too and no
amount of policy/data seeding can work around it. It is the direct blocker
for §4-22 (the no-code add-anything promise is unreachable at its very
first step for the type registry, and by construction for every other
PolicyGated-wrapped create affordance that uses the console's real action
vocabulary rather than the authoring canvas's view/edit/read_field
verbs).
Suggested fix direction (no code changed here, out of this audit's
lane): either (a) give authorize/authorize_bulk a proper runtime action
registry independent of AUTHORING_ACTIONS (the latter should stay scoped
to /policy/simulate and the no-code canvas), or (b) if Cedar activation
for the wider console is intentionally still dark/unimplemented, gate
PolicyGated on a feature flag so it doesn't silently blank out the entire
UI while Cedar enforcement is mid-rollout. File as its own charter — this
is bigger than a §4-22/23 UI polish item.
Attempted the full flow: register type via manager → codes drag/parse → module surface renders → create instance → lifecycle.
| Step | Result | Note |
|---|---|---|
| 1. Register a new type via Ontology Manager | BROKEN | Blocked entirely by Finding 1 — the "새 타입 이름" input and "타입 추가" button never render for any role. |
| 2. Codes drag/parse into the new type | UNTESTABLE | No type can be created to test this on. |
| 3. Module surface renders the new type | UNTESTABLE | Same. |
| 4. Create an instance of the new type | UNTESTABLE | Same — also separately gated by ontology.instance.* actions, which are equally outside AUTHORING_ACTIONS, so this step would be blocked a second, independent way even if step 1 were fixed by data alone. |
| 5. Lifecycle transition on the instance | UNTESTABLE | Same. |
§4-22's core promise ("사용자가 마주치는 모든 UI 요소는 그 자리에서 새 항목을
추가할 수 있어야 한다") is not met for the flagship case named in the
directive (온톨로지 타입 저작) in the current build. Every other
add-affordance gated the same way (table new-column config mode, enum-chip
new-value proposal, stat-bar new-stat, saved filter presets — all listed in
§4-22) should be assumed similarly at-risk wherever they route through
PolicyGated with a non-AUTHORING_ACTIONS action string; this audit did
not exhaustively re-test each one individually since the root cause and
fix are singular (Finding 1).
| Surface | Pin (click) | Split/lock (dblclick) | Tray/popout | objDrag source | Notes |
|---|---|---|---|---|---|
| Ontology Manager — type rail | N/T | N/T | N/T | Code present (OntologyManagerScreen.tsx:282, {...objDrag(type.code, type.title)}) but unverifiable at runtime — 0 types exist (Finding 1), so the rail is permanently empty and there is nothing to click/drag. |
Code inspection only. |
| 사용자 관리 (Users) table rows | FAIL | FAIL | N/T | FAIL — 0 [draggable="true"] elements on the page; grep confirms no objDrag/useWindowManager/usePinnedPanel import anywhere in UsersPage.tsx or src/console/identity/*.tsx. |
Clicking a row name does nothing (no pinned detail panel opens); it is plain text, not a button/objDrag source. Violates §4-20 ("모든 개체 표면 = 드래그 소스") for a live, populated, real object type (직원/사용자). |
| 메신저 (Messenger) | N/T | N/T | N/T | UNVERIFIED | MessengerConsoleScreen.tsx imports objDrag/window-model helpers per grep, but the scratch org has zero seeded threads/messages, so no chat bubble/chip existed to exercise. Not counted as a defect — environment data gap, not a confirmed code defect. |
| 통합 개요 (Overview) — action inbox / todo list | N/T | N/T | N/T | UNVERIFIED | Same — 0 rows (empty action inbox, empty todo list) in the scratch org; nothing to click or drag. |
Legend: PASS / FAIL = actually exercised in the browser with real data. N/T = not tested (no reachable target in this environment/time window). UNVERIFIED = code exists but could not be exercised for lack of data, not counted as a confirmed defect.
Where: web/src/pages/UsersPage.tsx and web/src/console/identity/*.tsx.
Repro: log in as SUPER_ADMIN, navigate to /settings/users (a page
with real, populated rows — the only surface in this audit with live data
that wasn't blocked by Finding 1). Rows render as plain <td> text, not
objDrag-wrapped chips/buttons; document.querySelectorAll('[draggable="true"]')
returns 0 on the page. Clicking a row name does not open a pinned detail
panel (§4.7 "상세 보기의 기본은 핀 패널이다"); it does nothing observable.
Impact: §4-20 requires every object-representing chip/row/code to be draggable and every detail-worthy row to open via the shared pin-panel click gesture. The user/employee object type — a first-class, universally present entity — does not participate in either. Medium severity: doesn't block other flows, but is a clear, easily-reproduced violation of a retroactive-audit directive on a live, non-empty surface.
Suggested fix direction: wrap the name cell in objDrag(user.code, user.name) and route the row click through the shared pin-panel affordance
(usePinnedPanel/cardGrab) already used by the Ontology Manager's type
rail, per §4-18's "중앙 정의 원칙" (reuse, don't reimplement).
- Time/turn budget did not allow re-populating the scratch org with enough seed data (dispatch orders, tickets, messenger threads) to exercise every major surface listed in §4-23's directive — most surfaces were empty by construction (fresh migration, no fixtures), and creating that data through the console itself is blocked by Finding 1 for anything beyond the two cold-start users. Where a surface could not be reached with real data, it is marked N/T or UNVERIFIED above rather than guessed at.
- No code was changed. Both findings above are new-charter-sized fixes (Finding 1 especially), not something to patch inline in this audit lane.