The plan's engine had a genuine authorization bypass and several fail-open
branches. Task 7 builds can() on this, so the source of truth is fixed before
that lands.
CRITICAL - anonymous callers bypassed every bound policy on a public:true
permission: the anonymous branch returned allow before the policy loop. A
permission marked "public, but not when embargoed" was fully open to
unauthenticated traffic, and the least-trusted caller got the weakest
evaluation. Policies now run on the anonymous path too; public relaxes the
identity requirement, never the policy requirement.
CRITICAL - the policy verdict check was truthiness-based, not an identity
check, so a policy returning {allowed: "yes"} or {allowed: 1} granted access.
It now compares against true.
A binding naming a policy the catalog lacks was skipped, granting whatever
the policy guarded; it now denies. Falsy and non-string subject ids fell
through to the anonymous path - {id: 0} became anonymous and {id: 123} reached
the store as a lookup key; only a non-empty string now identifies a subject.
Two design forks, ruled by the human: denies honour wildcards, so denying
"post:*" blocks post:delete instead of being accepted and doing nothing; and
permissionsFor subtracts denies, so composing it with permissionMatches
agrees with decide() rather than silently losing deny precedence.
Adds deniedBy() and six regression tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 'denials are audited' test assigned role editor, which holds post:*, so
decide(post:delete) was legitimately an ALLOW under the wildcard rule the
same task specifies. The test then asserted one audited denial and got zero.
Switched to moderator (post:comment:*), which genuinely lacks post:delete.
Caught by the Task 6 implementer running the transcribed test against the
transcribed implementation. Plan-origin defect, fixed under standing
authority.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The re-review confirmed the log-injection fix works for C0 and DEL, but
U+0085 (NEL) and U+2028/U+2029 pass through. Those are line terminators to
some log shippers and to JavaScript's own lexical grammar, so they can still
split a record downstream.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
consoleAuditSink interpolated subjectId, tenantId and reason straight into
the log line. A newline in any of them forges a second entry that reads as a
genuine audit record - the reviewer produced a fake
'[wrnexus:authz] allow admin:everything subject=root' line. Those values
trace back to request input.
Interpolated fields now go through logSafe(), which replaces control
characters. Adds the missing coverage the review flagged: consoleAuditSink
injection, malformed-sink handling, and memoryAuditSink.clear().
Plan-origin defect, fixed under standing authority to amend the plan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan's cachedPermissionStore used scopeKey + U+FFFD + subjectId as a
cache key with no escaping, so ('a', 'b<sep>c') and ('a<sep>b', 'c') collide
and one subject is served another's permissions. Subject and tenant ids are
unconstrained strings, so nothing prevented it.
Key is now JSON-encoded, and the global-write sweep tracks keys per subject
instead of substring-matching. Adds the two regression tests that were
missing: cross-tenant invalidation on a global write, and key collision.
Ruled by the human as plan-mandated; source of truth amended so a re-run of
the plan does not reintroduce the defect.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Global Constraints said the change was additive while Task 8 changed
authorizeDecision's 403 body. Ruled: the security fix governs; the
constraint now names it as the one approved exception.
- Task 6 defined permissionsFor and then re-implemented it inline in
decide. Both now call a single loadEffective helper.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifteen TDD tasks covering phases 1-3 of the approved design: registry,
catalog merge, PermissionStore with a shared conformance suite, caching
decorator, audit sink, resolution engine, request middleware and guards,
router discovery, database adapter, codegen, and the wrnexus authz CLI.
Phases 4 (.wrn view can()) and 5 (admin UI) are documented as deferred with
the reason each needs its own design pass.
Also folds in the authorizeDecision disclosure fix as Task 8, since the new
guards share its 403 shape.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Separates declaration (what permissions, roles, policies and attributes exist)
from assignment (who holds what), building on the decision primitives already
in advanced.ts rather than replacing them.
Covers the registry and app/authz discovery, the PermissionStore interface
with memory and db adapters, tenant-scoped assignments meeting the existing
TenantMembership, deny-wins precedence, fail-closed behaviour, the audit sink,
codegen and CLI introspection, and the seam for propagating subject context to
the inter-app communication system.
Records two decisions worth keeping: cross-app sharing needs no runtime
catalog distribution (declarations are static code in the shared package;
only assignments are shared, via the database), and can() stays off Context
to avoid a core -> authz dependency cycle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>