Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
7e46767
feat(auth): enforce purpose-bound route authorization
seonghobae Aug 28, 2026
0bd9723
test(auth): verify immutable authorization evidence
seonghobae Aug 28, 2026
5d9e6ec
style(auth): normalize unittest mock import
seonghobae Aug 28, 2026
23813c7
fix(auth): bound command correlation evidence
seonghobae Aug 28, 2026
f1aa012
fix(auth): preserve bounded command correlation
seonghobae Aug 28, 2026
ecb2359
fix(auth): retain tenant provenance and require caller kind
seonghobae Aug 28, 2026
4dfdc09
docs: track authorization gap integration status
seonghobae Aug 28, 2026
a778e3b
fix: bind authorization evidence to tenant scope
seonghobae Aug 29, 2026
c656ab9
fix(auth): reject malformed close bodies before authorization
seonghobae Aug 29, 2026
4868dba
fix(auth): reject caller-constructed decisions
seonghobae Aug 29, 2026
010c896
fix(auth): preserve decision provenance
seonghobae Aug 29, 2026
7b7bdda
fix(auth): seal authorization decisions
seonghobae Aug 29, 2026
a8d6ffd
fix(auth): remove forgeable issuance marker
seonghobae Aug 29, 2026
5b7bfc2
fix(auth): authorize malformed period closes
seonghobae Aug 29, 2026
89ae72d
test(auth): reserve reconciliation completion authority
seonghobae Sep 1, 2026
1593f2b
feat(auth): reserve reconciliation completion permission
seonghobae Sep 1, 2026
9e6fa3f
test(auth): version reconciliation permission expansion
seonghobae Sep 1, 2026
f9814c0
fix(auth): version expanded operation policy
seonghobae Sep 1, 2026
6222aac
docs(auth): version reconciliation completion authority
seonghobae Sep 1, 2026
5e2f742
docs(auth): doctor reconciliation completion policy expansion
seonghobae Sep 1, 2026
270bc60
docs: canonicalize reconciliation authorization references
seonghobae Sep 1, 2026
1f9da05
test(auth): require request-scoped principal resolution
seonghobae Sep 1, 2026
0fa27a8
ci(auth): repair request-scoped caller identity boundary
seonghobae Sep 1, 2026
8c561df
ci(auth): use src-aware Python path for focused repair
seonghobae Sep 1, 2026
14684f9
ci(auth): fix request-principal repair source quoting
seonghobae Sep 1, 2026
545da7d
ci(auth): allow bounded helper to publish verified repair
seonghobae Sep 1, 2026
d882ed4
fix(auth): resolve validated principal per request
github-actions[bot] Sep 1, 2026
5a985ef
docs(auth): trace request-scoped principal authority repair
seonghobae Sep 1, 2026
a55fff4
docs(auth): normalize doctoring whitespace
seonghobae Sep 1, 2026
5f85ab9
test(auth): reject malformed principal resolver output
seonghobae Sep 1, 2026
1433975
ci: run bounded malformed-principal repair
seonghobae Sep 1, 2026
811aaf7
fix(auth): fail closed on malformed principal output
Sep 1, 2026
fa0ac22
docs(auth): separate doctoring date and scope
seonghobae Sep 1, 2026
772a816
fix(auth): bound durable decision evidence
seonghobae Sep 1, 2026
6ef23db
test(auth): ratchet audit evidence bounds
seonghobae Sep 1, 2026
8b97fec
docs(auth): record evidence storage bound basis
seonghobae Sep 1, 2026
84f4cbb
docs(auth): canonicalize identity storage references
seonghobae Sep 1, 2026
0e300ad
fix(auth): align correlation storage units
seonghobae Sep 1, 2026
ef9ffac
test(auth): align multibyte correlation contract
seonghobae Sep 1, 2026
b00f016
docs(auth): align correlation evidence units
seonghobae Sep 1, 2026
dc1cb7b
test(auth): correct multibyte correlation boundary fixture
seonghobae Sep 1, 2026
f0dc028
test(auth): reserve exception-resolution permission
seonghobae Sep 1, 2026
764e5d0
feat(auth): reserve exception-resolution authority
seonghobae Sep 1, 2026
c7f558e
test(auth): advance completion policy vocabulary
seonghobae Sep 1, 2026
b9fbab4
docs(adr): separate exception-resolution permission
seonghobae Sep 1, 2026
71db019
docs(auth): trace exception-resolution policy v3
seonghobae Sep 1, 2026
27ca180
test: reserve unique authorization ADR identity
seonghobae Sep 2, 2026
ad0b900
docs: assign authorization ADR 0064 proposed
seonghobae Sep 2, 2026
ef58aea
docs: retire colliding authorization ADR 0055
seonghobae Sep 2, 2026
42e6c7d
fix: point repository contract at ADR 0064
seonghobae Sep 2, 2026
d6674b8
docs: point completion trace to ADR 0064
seonghobae Sep 2, 2026
04488f5
docs: point request-scope trace to ADR 0064
seonghobae Sep 2, 2026
1afd776
docs: point exception policy trace to ADR 0064
seonghobae Sep 2, 2026
4ef1f42
test: bind authorization traceability to ADR 0064
seonghobae Sep 2, 2026
9cdafb7
docs: point authorization traceability to ADR 0064
seonghobae Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

## [Unreleased]

- The accounting HTTP boundary now resolves a validated principal independently for every request through a trusted `request_principal_resolver`; the public server factory no longer accepts one reusable principal whose permissions every reachable client could inherit. Identity-adapter failures fail closed before an allowed audit decision is written.
- Authorization evidence persistence now accepts only unchanged decisions issued by the
authorization evaluator; caller-constructed or post-issuance-mutated `allowed` decisions
cannot be promoted into durable audit evidence, while copied evaluator decisions retain
their provenance.
- Malformed or non-object period-close request bodies no longer create an allowed hard-close authorization record before the request is rejected; authorization evidence is reserved for a structurally valid close command.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Malformed-close documentation contradicts runtime

The changelog says malformed close bodies never create allowed evidence. _post_authorization_operation classifies them as hard closes, records an allow, then returns 400.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


- Authorization principals now require an explicit `principal_kind` (`human`, `service`, or
`agent`) so omitted host identity classification cannot silently receive human high-impact
authority. Authorization evidence retains both principal and requested tenant references, and
unprovisioned tenant diagnostics remain fail-closed without being mislabeled as audit-store loss.
- Authorization evidence now rejects a requested tenant that differs from the tenant scope used for
persistence, preventing cross-scope audit claims at the application evidence boundary.
- Added purpose-bound application authorization at the HTTP boundary: a trusted host adapter must
supply validated opaque principal/purpose evidence and every accounting route maps to an explicit
permission before domain work. Missing, unknown, tenant-mismatched, insufficient, and
agent-originated high-impact decisions fail closed; immutable tenant-scoped decision evidence is
retained by migration 0015. The tenant header remains identity binding, not caller authority.
- Added the deterministic bank-reconciliation proposal engine from ADR 0054: stable provider, end-to-end, and account-servicer references take precedence; exact decimal amount and currency evidence must agree; exact-money plus bounded-date fallback is permitted only for one unique candidate; and ambiguity, conflicts, out-of-window evidence, or no candidate produce explicit abstention with an operator next action. Match proposals are read-only evidence with no automatic journal posting; any accounting adjustment must re-enter the existing journal command boundary.
- Added the exact book-to-bank reconciliation bridge with exact Decimal equations, fail-closed one minor unit differences, statement-population and book-population provenance, no automatic journal posting, and ADR 0054.
- Added a read-only reconciliation close-review projection over deterministic decisions and the exact bridge: controllers receive exact bank/book/reconciled/outstanding/unexplained Decimal values, immutable run and population provenance, preceding-run deltas, unresolved statement-entry references, and an explicit next action. JSON and CSV exports preserve money as decimal strings. `suitable_for_period_close_review` is evidence eligibility only; the projection cannot approve reconciliation, close a period, or post a journal. ADR 0054 records the authority boundary.
Expand All @@ -15,7 +33,7 @@

## [0.1.0] - 2026-08-26

First tagged release of the accounting system of record foundation: exact-decimal proposal validation, idempotent posting, append-only reversal, trial balance, financial statements, fiscal-period close control, VAT/HomeTax fail-closed evidence, durable outbox, tenant-scoped row-level security, immutable ISO 20022 camt.053.001.14 bank-statement evidence registry, and action-guiding caller-facing copy. Pre-Alpha (Development Status :: 3 - Alpha planned next); foreign exchange, deterministic reconciliation matching, purpose-bound authorization, and live NTS transmission remain explicit future scope.
First tagged release of the accounting system of record foundation: exact-decimal proposal validation, idempotent posting, append-only reversal, trial balance, financial statements, fiscal-period close control, VAT/HomeTax fail-closed evidence, durable outbox, tenant-scoped row-level security, immutable ISO 20022 camt.053.001.14 bank-statement evidence registry, and action-guiding caller-facing copy. Pre-Alpha (Development Status :: 3 - Alpha planned next); foreign exchange, deterministic reconciliation matching, and live NTS transmission remain explicit future scope.

- Hardened every caller-facing message against the two product-writing rules: no internal implementation boundary (driver module, internal class or schema-object names) reaches a customer-visible error, and every failure names the customer's next action ("Supply …, then retry …"). The database-driver absence path now routes to the platform operator instead of naming internals; tenant-scope and runtime-binding failures describe the deployment fact rather than internal objects; unmapped account roles, unbalanced proposals, reversal conflicts, and close-key reuse all carry explicit next-action guidance.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ factory/runner and provide the tenant-bound host boundary explicitly.
`unittest` discovery also runs `tests/test_postgres_posting.py`, which needs a
reachable PostgreSQL 18 instance and `ACCOUNTING_DATABASE_URL` (CI uses
`postgresql://postgres:postgres@127.0.0.1:5432/accounting_test` and applies
the checked-in migration chain through `database/migrations/0014_reconciliation_candidate_allocation.sql`). Persistence is still
the checked-in migration chain through `database/migrations/0015_authorization_decision_evidence.sql`). Persistence is still
local to this repository; it is not a Naruon or sibling checkout.

Optional import smoke after the editable install above:
Expand Down
101 changes: 101 additions & 0 deletions database/migrations/0015_authorization_decision_evidence.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
BEGIN;

-- Purpose-bound application decisions are append-only evidence. The host identity adapter validates
-- credentials; this table retains only the opaque claims and decision needed for accounting audit.
-- Identity references are normalized CWL URNs and use the 255-octet authorization profile ceiling;
-- raw external claims remain at the trusted identity-provider boundary. Operation/purpose limits
-- mirror the executable code contract, permission is two bounded code components, and the existing
-- 512-character correlation evidence ceiling is enforced again at PostgreSQL so direct SQL cannot
-- inflate storage while multibyte command identities retain the same contract as the HTTP boundary.
CREATE TABLE accounting_integration.authorization_decision_record (
authorization_decision_record_id uuid PRIMARY KEY DEFAULT uuidv7(),
tenant_account_id uuid NOT NULL,
principal_reference text NOT NULL
CHECK (
btrim(principal_reference) <> ''
AND octet_length(principal_reference) <= 255
AND principal_reference ~ '^urn:cwl:[A-Za-z0-9_:.-]+$'
),
principal_tenant_reference text NOT NULL
CHECK (
btrim(principal_tenant_reference) <> ''
AND octet_length(principal_tenant_reference) <= 255
AND principal_tenant_reference ~ '^urn:cwl:[A-Za-z0-9_:.-]+$'
),
requested_tenant_reference text NOT NULL
CHECK (
btrim(requested_tenant_reference) <> ''
AND octet_length(requested_tenant_reference) <= 255
AND requested_tenant_reference ~ '^urn:cwl:[A-Za-z0-9_:.-]+$'
),
authentication_context_reference text NOT NULL
CHECK (
btrim(authentication_context_reference) <> ''
AND octet_length(authentication_context_reference) <= 255
AND authentication_context_reference ~ '^urn:cwl:[A-Za-z0-9_:.-]+$'
),
credential_evidence_reference text NOT NULL
CHECK (
btrim(credential_evidence_reference) <> ''
AND octet_length(credential_evidence_reference) <= 255
AND credential_evidence_reference ~ '^urn:cwl:[A-Za-z0-9_:.-]+$'
),
operation_code text NOT NULL
CHECK (
octet_length(operation_code) <= 64
AND operation_code ~ '^[a-z][a-z0-9_]{1,63}$'
),
permission_code text NOT NULL
CHECK (
octet_length(permission_code) <= 129
AND (
permission_code = ''
OR permission_code ~ '^[a-z][a-z0-9_]{1,63}\.[a-z][a-z0-9_]{1,63}$'
)
),
purpose_code text NOT NULL
CHECK (
octet_length(purpose_code) <= 64
AND purpose_code ~ '^[a-z][a-z0-9_]{1,63}$'
),
policy_version text NOT NULL
CHECK (btrim(policy_version) <> '' AND octet_length(policy_version) <= 64),
decision_code text NOT NULL CHECK (decision_code IN ('allowed', 'denied')),
correlation_reference text NOT NULL
CHECK (btrim(correlation_reference) <> '' AND char_length(correlation_reference) <= 512),
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
FOREIGN KEY (tenant_account_id)
REFERENCES accounting_core.tenant_account (tenant_account_id),
UNIQUE (tenant_account_id, authorization_decision_record_id)
);
Comment thread
seonghobae marked this conversation as resolved.

CREATE INDEX authorization_decision_scope_index
ON accounting_integration.authorization_decision_record (
tenant_account_id, recorded_at, authorization_decision_record_id
);

CREATE OR REPLACE FUNCTION accounting_core.reject_authorization_decision_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION
'authorization decision evidence is append-only (authorization_evidence_immutable)'
USING ERRCODE = '23514';
END;
$$;

CREATE TRIGGER authorization_decision_immutable_guard
BEFORE UPDATE OR DELETE ON accounting_integration.authorization_decision_record
FOR EACH ROW EXECUTE FUNCTION accounting_core.reject_authorization_decision_mutation();

ALTER TABLE accounting_integration.authorization_decision_record ENABLE ROW LEVEL SECURITY;
ALTER TABLE accounting_integration.authorization_decision_record FORCE ROW LEVEL SECURITY;
CREATE POLICY authorization_decision_tenant_isolation
ON accounting_integration.authorization_decision_record
USING (tenant_account_id = accounting_core.current_tenant_account_id())
WITH CHECK (tenant_account_id = accounting_core.current_tenant_account_id());

REVOKE ALL ON accounting_integration.authorization_decision_record FROM PUBLIC;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Runtime insert grant needs deployment verification

The migration revokes public access without granting INSERT. Production authorization returns 503 for every routed request unless deployment provisions the runtime role explicitly.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


COMMIT;
4 changes: 3 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Metering and billing remain authoritative for usage, pricing, invoice intent, pa
| `trial_balance` | Deterministic aggregation from the authoritative journal population or hard-close snapshot |
| `reporting_projection` | Versioned statements, ledgers, balances, rollforwards and close-package reads |
| `integration_outbox` | Transactional publication evidence and append-only audit history |
| `purpose_bound_authorization` | Host-validated principal decisions, route-to-permission mapping, and immutable authorization evidence |
| `tax_interface` | VAT register and fail-closed HomeTax submission evidence; no NTS transport in this foundation |
| `bank_statement_registry` | Immutable camt.053.001.14 statement/entry evidence, bank-account-to-book mapping, and host artifact locators |

Expand All @@ -56,7 +57,7 @@ Deferred constraint triggers recompute persisted journal lines at commit. A dura

The application runtime database login is separate from the migration owner and from administrative / break-glass identities. Tenant-scoped tables use RLS and the runtime path is tested with a non-owner, non-superuser, non-`BYPASSRLS` login. Purpose-limited soft-close exceptions use explicit role membership; ordinary runtime identities do not inherit `accounting_closing_writer`.

The HTTP surface currently binds tenant identity through the configured AIS tenant plus `X-CWL-Tenant-Reference`. That header is not a general credential. Production exposure therefore requires a trusted host or gateway that authenticates the caller before traffic reaches this process. Purpose-bound application authorization is tracked separately from the database-credential boundary and must not be inferred from request-body fields, model output, or database GUCs.
The HTTP surface binds tenant identity through the configured AIS tenant plus `X-CWL-Tenant-Reference`, and maps every accounting route to a purpose-bound permission before domain dispatch. That header is not a general credential. Production exposure therefore requires a trusted host or gateway whose request-scoped resolver authenticates each caller before accounting authorization and returns a validated `AuthenticatedPrincipal` with an explicit `human`, `service`, or `agent` kind; the server does not cache one caller identity for later requests. Missing, unknown, tenant-mismatched, or insufficient decisions fail closed and are retained with both principal and requested tenant references. Authority is never inferred from request-body fields, model output, or database GUCs.

## Posting transaction

Expand Down Expand Up @@ -130,6 +131,7 @@ Shared fiscal-calendar dates do not collapse independent accounting books into o
12. `database/migrations/0012_bank_assignment_command_identity.sql` — tenant-scoped bank-account-assignment command identity, replay/conflict evidence, and the active book-scope uniqueness guard.
13. `database/migrations/0013_reconciliation_run_exception_evidence.sql` — durable reconciliation-run and exception evidence required by the installed bank-reconciliation control chain.
14. `database/migrations/0014_reconciliation_candidate_allocation.sql` — durable reconciliation candidate, single-approved match, and exact statement/journal allocation rows with forced tenant RLS.
15. `database/migrations/0015_authorization_decision_evidence.sql` — tenant-scoped, append-only application authorization decisions.

## Durable soft-close command evidence

Expand Down
10 changes: 9 additions & 1 deletion docs/OPERABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@

## Deployment preconditions

Use PostgreSQL 18 and keep the migration owner, application runtime login and administrative / break-glass identities separate. Apply migrations in numeric order through `0014_reconciliation_candidate_allocation.sql` before starting the service. Do not run the application with a table-owner, superuser or `BYPASSRLS` login.
Use PostgreSQL 18 and keep the migration owner, application runtime login and administrative / breakglass identities separate. Apply migrations in numeric order through `0015_authorization_decision_evidence.sql` before starting the service. Do not run the application with a table-owner, superuser or `BYPASSRLS` login.

Required environment values are deployment-specific. At minimum, configure the accounting database URL and bind this AIS process to exactly one tenant reference. Secrets belong in an approved secret store; do not place database passwords, NTS credentials, bearer tokens or provider secrets in journal payloads, logs or outbox events.

`X-CWL-Tenant-Reference` is a tenant-binding header, **not** caller authentication. The standalone runner binds to `127.0.0.1` when no host is explicitly supplied. Do not expose the HTTP listener directly to untrusted networks. A non-loopback bind must be an explicit deployment decision behind a trusted authentication / authorization boundary, and the validated caller tenant must match the AIS tenant binding.

The trusted host identity adapter must validate issuer, audience, expiry, signature, and token
binding before constructing `AuthenticatedPrincipal`, and must pass an explicit `principal_kind` of
`human`, `service`, or `agent`. AIS rejects an omitted kind rather than classifying it as a human.
Provide `request_principal_resolver` as the trusted host adapter: it validates each incoming request and returns that request's `AuthenticatedPrincipal`. A static server-wide principal is not supported. The standalone runner supplies no resolver and therefore denies every accounting route except `/healthz`. Grant the runtime login INSERT access to
`accounting_integration.authorization_decision_record` and retain its append-only authorization
decision evidence. Never forward bearer tokens, request-body permission claims, or model output.

## Database installation

Apply, in order:
Expand All @@ -27,6 +34,7 @@ database/migrations/0011_bank_statement_evidence.sql
database/migrations/0012_bank_assignment_command_identity.sql
database/migrations/0013_reconciliation_run_exception_evidence.sql
database/migrations/0014_reconciliation_candidate_allocation.sql
database/migrations/0015_authorization_decision_evidence.sql
```

Migration `0007_runtime_tenant_binding.sql` replaces caller-selected tenant authority with owner-controlled runtime-login binding. Migration `0008_fiscal_period_open_command.sql` adds forced-RLS, append-only command evidence so fiscal-period-open retries are bound to the original tenant key and source hash. Both must be installed before runtime database privileges are treated as production-ready.
Expand Down
12 changes: 12 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ Database administration is not business posting authority. Migration owners and

Purpose-bound application authorization is a separate control from PostgreSQL privileges. Request-body fields, model text, headers supplied by an untrusted client and database GUC values cannot grant posting, reversal, close or tax authority.

The trusted host identity adapter must validate issuer, audience, expiry, signature, and token binding for every request before returning that request's `AuthenticatedPrincipal` to AIS. The production HTTP factory accepts a request-scoped resolver rather than a reusable server-wide principal. It must pass the explicit `principal_kind` value
`human`, `service`, or `agent`; AIS has no implicit kind default, so omission is rejected before
authorization. The HTTP boundary maps each route to a stable operation and requires the corresponding
versioned permission; soft-close and hard-close are independent permissions. Missing, unknown,
tenant-mismatched, insufficient, or agent-originated high-impact decisions fail closed before
`accept` or `lookup` executes. Each decision is appended to tenant-scoped forced-RLS
`accounting_integration.authorization_decision_record`, including both principal and requested tenant
references, without raw tokens or full policy documents. The persistence boundary rejects a record
whose requested tenant differs from its storage tenant and accepts only unchanged decisions issued
by the authorization evaluator, preventing caller-constructed or mutated allow evidence. The
standalone runner has no principal by default and denies all accounting routes except health status.

## PostgreSQL runtime identities

Production runtime access uses a non-owner, non-superuser, non-`BYPASSRLS` login with only the table / schema privileges required by supported application paths. Tenant-scoped authoritative tables both enable and `FORCE ROW LEVEL SECURITY`; the runtime identity is still deliberately non-owner so ordinary service access never depends on owner-bypass semantics. Real PostgreSQL integration tests must prove an actual restricted login can execute a supported same-tenant posting/read path while cross-tenant rows remain invisible and the login is neither owner, superuser nor `BYPASSRLS`.
Expand Down
Loading
Loading