From 7e467676330456f972e4609f952c4d2c5cab87ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:25:25 +0900 Subject: [PATCH 01/55] feat(auth): enforce purpose-bound route authorization --- CHANGELOG.md | 7 +- README.md | 2 +- .../0015_authorization_decision_evidence.sql | 55 ++++ docs/ARCHITECTURE.md | 4 +- docs/OPERABILITY.md | 10 +- docs/SECURITY.md | 9 + docs/adr/0055-purpose-bound-authorization.md | 58 ++++ docs/doctoring/STANDARD_TRACEABILITY.md | 1 + scripts/validate_repository.py | 3 + .../__init__.py | 18 ++ .../authorization.py | 221 ++++++++++++++ .../http_api.py | 191 +++++++++++- .../persistence.py | 12 + tests/test_authorization.py | 273 ++++++++++++++++++ tests/test_database_migration_contracts.py | 17 ++ ...st_foundation_install_manifest_contract.py | 28 ++ tests/test_postgres_posting.py | 98 ++++++- 17 files changed, 998 insertions(+), 9 deletions(-) create mode 100644 database/migrations/0015_authorization_decision_evidence.sql create mode 100644 docs/adr/0055-purpose-bound-authorization.md create mode 100644 src/accounting_information_platform/authorization.py create mode 100644 tests/test_authorization.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f54fc4ae..19806766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +- 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. @@ -15,7 +20,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. diff --git a/README.md b/README.md index 20b5c68f..4e157928 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/database/migrations/0015_authorization_decision_evidence.sql b/database/migrations/0015_authorization_decision_evidence.sql new file mode 100644 index 00000000..a11bd9cc --- /dev/null +++ b/database/migrations/0015_authorization_decision_evidence.sql @@ -0,0 +1,55 @@ +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. +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) <> ''), + requested_tenant_reference text NOT NULL CHECK (btrim(requested_tenant_reference) <> ''), + authentication_context_reference text NOT NULL + CHECK (btrim(authentication_context_reference) <> ''), + credential_evidence_reference text NOT NULL + CHECK (btrim(credential_evidence_reference) <> ''), + operation_code text NOT NULL CHECK (btrim(operation_code) <> ''), + permission_code text NOT NULL, + purpose_code text NOT NULL CHECK (btrim(purpose_code) <> ''), + policy_version text NOT NULL CHECK (btrim(policy_version) <> ''), + decision_code text NOT NULL CHECK (decision_code IN ('allowed', 'denied')), + correlation_reference text NOT NULL CHECK (btrim(correlation_reference) <> ''), + 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) +); + +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; + +COMMIT; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 68c50566..cf8cad36 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 | @@ -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 that authenticates the caller before traffic reaches this process and supplies the validated `AuthenticatedPrincipal` context. Missing, unknown, tenant-mismatched, or insufficient decisions fail closed and are retained as authorization evidence. Authority is never inferred from request-body fields, model output, or database GUCs. ## Posting transaction @@ -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 diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 4e5aca37..c0a19169 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -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 / break‑glass 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`. Pass that context explicitly to the server; +the standalone runner supplies no principal 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: @@ -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. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index d009a291..daf78ec1 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -25,6 +25,15 @@ 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 +before passing an `AuthenticatedPrincipal` to AIS. 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` without raw +tokens or full policy documents. 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`. diff --git a/docs/adr/0055-purpose-bound-authorization.md b/docs/adr/0055-purpose-bound-authorization.md new file mode 100644 index 00000000..4086db75 --- /dev/null +++ b/docs/adr/0055-purpose-bound-authorization.md @@ -0,0 +1,58 @@ +# ADR 0055: Purpose-bound application authorization + +## Status + +Accepted + +## Context + +Tenant authentication identifies the accounting scope but does not establish that a caller may +read a report, post a proposal, reverse a journal, close a period, publish an outbox event, or +submit tax evidence. PostgreSQL role and forced-RLS controls remain necessary database defenses, +but they do not replace an application decision made before a route invokes domain work. + +## Decision + +The trusted host identity adapter supplies an immutable `AuthenticatedPrincipal` containing only +validated opaque principal, tenant, authentication-context, purpose, permission, and credential- +evidence references. It does not pass bearer tokens, policy documents, or model output into AIS. + +The HTTP boundary maps every accounting route to a stable operation code before invoking `accept` +or `lookup`. A missing, unknown, tenant-mismatched, agent-originated high-impact, or insufficient +permission decision fails closed with HTTP 403. Soft-close and hard-close have independent +permissions. Request-body fields, tenant headers, database GUCs, Billing documents, and model +text cannot grant authority. + +Every routed decision is appended to the tenant-scoped, forced-RLS +`accounting_integration.authorization_decision_record` table. The record keeps the policy version, +decision, principal/purpose evidence, operation, required permission, request tenant, and bounded +correlation identity. It never stores raw credentials. Database mutation triggers make the evidence +append-only. + +The standalone runner has no authenticated principal by default and therefore exposes only health +status until a trusted host adapter supplies a validated context to +`create_journal_proposal_server` or `run_journal_proposal_server`. + +## Consequences + +- Catalog readers do not implicitly receive posting or close authority. +- A service or human principal can receive explicit permissions through the same host-neutral port. +- Agent/model contexts are denied high-impact operations by default. +- Authorization evidence is durable and tenant isolated, while journal and command evidence keeps + its existing transaction boundaries. +- Deployment must grant the runtime login INSERT access to the authorization evidence table and + provision the host adapter before enabling accounting routes. + +## Alternatives rejected + +- Treating `X-CWL-Tenant-Reference` as a bearer credential would make tenant identity equal to + authority. +- Reading permission claims from request JSON or model text would let an untrusted caller promote + itself. +- Storing raw JWTs or full policy documents would add unnecessary secret and PII exposure. + +## Evidence + +`src/accounting_information_platform/authorization.py` owns the immutable decision contract and +`http_api.py` performs route mapping before domain dispatch. Migration +`0015_authorization_decision_evidence.sql` owns tenant isolation and append-only audit evidence. diff --git a/docs/doctoring/STANDARD_TRACEABILITY.md b/docs/doctoring/STANDARD_TRACEABILITY.md index c35fd19c..179592f9 100644 --- a/docs/doctoring/STANDARD_TRACEABILITY.md +++ b/docs/doctoring/STANDARD_TRACEABILITY.md @@ -17,6 +17,7 @@ | SLSA 1.2 / SPDX 2.3 / GitHub artifact attestations | Exact-head package evidence builds the wheel twice from a source-derived `SOURCE_DATE_EPOCH`, requires byte-identical SHA-256 digests, emits a deterministic SPDX 2.3 SBOM plus `source-provenance.json`, and makes `SHA256SUMS` cover the wheel, SBOM and source-provenance manifest. After checksum verification, the rebuilt wheel is installed with `--require-hashes` from a requirements line that carries the measured `--hash=sha256:` digest. The intermediate public-API smoke test imports the source tree over `PYTHONPATH` instead of an unhashed editable install. The manifest binds the verified source SHA to the wheel digest and SBOM digest before merge. Pull-request-controlled build/test code runs with `contents: read` only; OIDC, attestation and artifact-metadata write permissions are isolated in a distinct push-only `integrated-attestations` job. That job depends on the successful foundation build, downloads the immutable SHA-named evidence bundle, re-verifies checksums and `source_sha == github.sha`, and only then creates GitHub OIDC-backed signed provenance and SBOM attestations on integrated `develop`/`main` heads. A new runtime dependency fails closed until the SBOM generator represents its dependency relationship. This is evidence readiness, not a claimed SLSA level or certification | Accounting Foundation CI, `scripts/generate_supply_chain_evidence.py`, supply-chain evidence tests, GitHub workflow-permissions/OIDC/artifact-attestation guidance, and ADR 0048 | | OSV-Scanner / OSV.dev vulnerability data | Pull-request dependency evidence is tied to the immutable PR head and an independently fetched live base tip. The gate records dependency-manifest diffs and SHA-256 values, rejects stale/non-ancestor base identity, and scans the complete hash-locked exact-head Python dependency set with a digest-pinned OSV-Scanner image. A known vulnerability, scanner failure, skipped/unavailable evidence path or wrong checkout identity is non-passing; aggregate organization workflow success cannot substitute for an unexecuted dependency-review step | `exact-head-dependency-diff` CI job, `tests/test_dependency_review_contract.py`, OSV-Scanner source/lockfile guidance, and ADR 0048 | | AICPA Trust Services Criteria (SOC 2) | Auditors read an append-only history of posted, reversed, and closed facts from existing `outbox_event` rows, including already-published rows, without marking publish. Controllers also list stored `journal_reversal` lineage and durable hard-close receipts over HTTP without SQL. A HomeTax filing command fail-closes and persists a rejected receipt when the VAT register or the purpose-limited HomeTax credential is missing, and this slice never claims `transmitted` | HTTP audit-event history, HTTP journal-reversal list, HTTP period-close list, HTTP fail-closed HomeTax submission, ADR 0027, ADR 0029, ADR 0030, and ADR 0046 | +| CWL purpose-bound authorization contract | Tenant identity is not authority: a trusted host adapter supplies validated opaque principal, purpose, permission, and authentication evidence; every route maps to a versioned operation decision; high-impact agent/model requests fail closed; and each accepted or denied decision is retained as append-only tenant-scoped evidence without raw credentials | `AuthenticatedPrincipal`, route authorization regressions, migration 0015, and ADR 0055 | | W3C PROV-O | Preserve entity, activity, agent, derivation, and attribution references across source proposal, posting, and append-only journal reversal lineage. The in-memory posting oracle scopes those identities by tenant and refuses to overwrite a posted `proposal_id`. Checked-in migrations cannot `UPDATE` or `DELETE` `general_journal` or `journal_entry_line` | Source-reference and receipt contracts, HTTP journal-reversal list, repository migration validation, ADR 0003, and ADR 0029 | | ISO/IEC/IEEE 42010:2022 | Keep stakeholder concerns, authority boundaries, architecture views, and decisions explicit | Architecture and ADR set | | JSON Schema Draft 2020-12 | Close external objects, version contracts, reject extra fields, and validate exact value formats. Policy-manifest `account_mappings` are unique by `account_role_code` before policy load because `uniqueItems` compares whole objects | Three contract schemas, `load_accounting_policy`, ADR 0005, and ADR 0007 | diff --git a/scripts/validate_repository.py b/scripts/validate_repository.py index 99299c4f..97958e04 100644 --- a/scripts/validate_repository.py +++ b/scripts/validate_repository.py @@ -23,6 +23,7 @@ ".github/workflows/ci.yml", "src/accounting_information_platform/__init__.py", "src/accounting_information_platform/accept.py", + "src/accounting_information_platform/authorization.py", "src/accounting_information_platform/bank_statement.py", "src/accounting_information_platform/billing_pull.py", "src/accounting_information_platform/core.py", @@ -47,6 +48,7 @@ "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", "docs/PRD.md", "docs/TRD.md", "docs/ARCHITECTURE.md", @@ -108,6 +110,7 @@ "docs/adr/0050-postgresql-concurrency-hot-partition.md", "docs/adr/0051-accounting-book-period-control.md", "docs/adr/0052-bank-statement-evidence-registry.md", + "docs/adr/0055-purpose-bound-authorization.md", "docs/doctoring/REFERENCES.md", "docs/doctoring/STANDARD_TRACEABILITY.md", "docs/superpowers/specs/2026-08-16-accounting-information-platform-design.md", diff --git a/src/accounting_information_platform/__init__.py b/src/accounting_information_platform/__init__.py index 871b78ac..61eda389 100644 --- a/src/accounting_information_platform/__init__.py +++ b/src/accounting_information_platform/__init__.py @@ -70,6 +70,16 @@ parse_bank_statement_payload, ) from .http_api import create_journal_proposal_server, run_journal_proposal_server +from .authorization import ( + AUTHORIZATION_POLICY_VERSION, + AuthenticatedPrincipal, + AuthorizationDecision, + authorize, + period_close_operation, + permission_for_operation, + record_authorization_decision, + require_authorization, +) from .ingest import ingest_journal_proposal from .persistence import PostgresPostingLedger from .migration_install import apply_foundation_migration @@ -78,6 +88,9 @@ "AccountBalance", "AccountingPolicy", "AccountingValidationError", + "AUTHORIZATION_POLICY_VERSION", + "AuthenticatedPrincipal", + "AuthorizationDecision", "IdempotencyConflictError", "JournalLineProposal", "JournalProposal", @@ -102,6 +115,7 @@ "accept_period_open", "accept_pulled_proposals", "apply_foundation_migration", + "authorize", "create_journal_proposal_server", "ingest_journal_proposal", "load_adapter_manifest", @@ -137,8 +151,12 @@ "load_accounting_policy", "load_chart_account_mapping", "parse_bank_statement_payload", + "period_close_operation", + "permission_for_operation", "publish_outbox_event", "pull_journal_proposal", "pull_validated_journal_proposals", "run_journal_proposal_server", + "record_authorization_decision", + "require_authorization", ] diff --git a/src/accounting_information_platform/authorization.py b/src/accounting_information_platform/authorization.py new file mode 100644 index 00000000..07714872 --- /dev/null +++ b/src/accounting_information_platform/authorization.py @@ -0,0 +1,221 @@ +"""Purpose-bound authorization contracts for the accounting HTTP boundary. + +The host identity adapter is responsible for validating the credential. AIS receives only the +opaque, validated claims needed to make an operation decision; bearer material never enters this +module or the accounting domain. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from types import MappingProxyType +from typing import Mapping + +from .core import _require_code, _require_reference + + +AUTHORIZATION_POLICY_VERSION = "accounting-authorization-v1" +_PERMISSION_PATTERN = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$") +_PRINCIPAL_KINDS = frozenset(("human", "service", "agent")) + +_OPERATION_PERMISSIONS: Mapping[str, str] = MappingProxyType( + { + "read_catalog": "accounting.read_catalog", + "read_journal": "accounting.read_journal", + "read_financial_statement": "accounting.read_financial_statement", + "read_close": "accounting.read_close", + "read_audit": "accounting.read_audit", + "read_tax_artifact": "accounting.read_tax_artifact", + "read_bank_statement": "accounting.read_bank_statement", + "read_receipt": "accounting.read_receipt", + "post_proposal": "accounting.post_proposal", + "post_adjustment": "accounting.post_adjustment", + "reverse_journal": "accounting.reverse_journal", + "open_period": "accounting.open_period", + "soft_close_period": "accounting.soft_close_period", + "hard_close_period": "accounting.hard_close_period", + "publish_outbox": "accounting.publish_outbox", + "submit_tax_artifact": "accounting.submit_tax_artifact", + "manage_bank_account": "accounting.manage_bank_account", + "ingest_bank_statement": "accounting.ingest_bank_statement", + } +) + +_HIGH_IMPACT_OPERATIONS = frozenset( + { + "post_proposal", + "post_adjustment", + "reverse_journal", + "open_period", + "soft_close_period", + "hard_close_period", + "publish_outbox", + "submit_tax_artifact", + "manage_bank_account", + "ingest_bank_statement", + } +) + + +@dataclass(frozen=True, slots=True) +class AuthenticatedPrincipal: + """Validated opaque identity claims supplied by a trusted host adapter.""" + + principal_reference: str + tenant_reference: str + authentication_context_reference: str + granted_permission_codes: frozenset[str] + purpose_code: str + credential_evidence_reference: str + principal_kind: str = "human" + + def __post_init__(self) -> None: + """Reject malformed identity evidence before it reaches route authorization.""" + for value, label in ( + (self.principal_reference, "principal reference"), + (self.tenant_reference, "tenant reference"), + (self.authentication_context_reference, "authentication context reference"), + (self.credential_evidence_reference, "credential evidence reference"), + ): + _require_reference(value, label) + _require_code(self.purpose_code, "purpose code") + if self.principal_kind not in _PRINCIPAL_KINDS: + raise ValueError("principal kind must be human, service, or agent") + permissions = frozenset(self.granted_permission_codes) + if any(_PERMISSION_PATTERN.fullmatch(permission) is None for permission in permissions): + raise ValueError("permission codes must use domain.operation syntax") + object.__setattr__(self, "granted_permission_codes", permissions) + + +@dataclass(frozen=True, slots=True) +class AuthorizationDecision: + """Immutable decision evidence suitable for durable authorization audit storage.""" + + principal_reference: str + tenant_reference: str + requested_tenant_reference: str + authentication_context_reference: str + credential_evidence_reference: str + operation_code: str + permission_code: str + purpose_code: str + policy_version: str + decision_code: str + allowed: bool + + +def permission_for_operation(operation_code: str) -> str | None: + """Return the exact permission required by *operation_code*, if it is registered.""" + return _OPERATION_PERMISSIONS.get(operation_code) + + +def authorize( + principal: AuthenticatedPrincipal | None, + requested_tenant_reference: str, + operation_code: str, +) -> AuthorizationDecision: + """Evaluate one operation without accepting authority from request data or model output.""" + _require_reference(requested_tenant_reference, "requested tenant reference") + permission_code = permission_for_operation(operation_code) or "" + if principal is None: + return AuthorizationDecision( + principal_reference="urn:cwl:principal:unauthenticated", + tenant_reference=requested_tenant_reference, + requested_tenant_reference=requested_tenant_reference, + authentication_context_reference="urn:cwl:authentication:none", + credential_evidence_reference="urn:cwl:evidence:none", + operation_code=operation_code, + permission_code=permission_code, + purpose_code="unauthenticated", + policy_version=AUTHORIZATION_POLICY_VERSION, + decision_code="denied", + allowed=False, + ) + tenant_matches = principal.tenant_reference == requested_tenant_reference + agent_restricted = principal.principal_kind == "agent" and operation_code in _HIGH_IMPACT_OPERATIONS + allowed = ( + bool(permission_code) + and tenant_matches + and not agent_restricted + and permission_code in principal.granted_permission_codes + ) + return AuthorizationDecision( + principal_reference=principal.principal_reference, + tenant_reference=principal.tenant_reference, + requested_tenant_reference=requested_tenant_reference, + authentication_context_reference=principal.authentication_context_reference, + credential_evidence_reference=principal.credential_evidence_reference, + operation_code=operation_code, + permission_code=permission_code, + purpose_code=principal.purpose_code, + policy_version=AUTHORIZATION_POLICY_VERSION, + decision_code="allowed" if allowed else "denied", + allowed=allowed, + ) + + +def require_authorization( + principal: AuthenticatedPrincipal | None, + requested_tenant_reference: str, + operation_code: str, +) -> AuthorizationDecision: + """Return allowed decision evidence or raise a caller-safe fail-closed error.""" + decision = authorize(principal, requested_tenant_reference, operation_code) + if decision.allowed: + return decision + if not decision.permission_code: + raise PermissionError(f"unknown accounting operation {operation_code}; authorization is denied") + raise PermissionError( + f"authorization denied for operation {operation_code}; obtain permission " + f"{decision.permission_code} for purpose {decision.purpose_code}, then retry" + ) + + +def period_close_operation(period_status_code: object) -> str: + """Map a period-close status to its independent authorization operation.""" + if period_status_code == "soft_closed": + return "soft_close_period" + if period_status_code in (None, "", "hard_closed"): + return "hard_close_period" + return "unknown_period_close_operation" + + +def record_authorization_decision( + database_url: str, + tenant_reference: str, + decision: AuthorizationDecision, + correlation_reference: str, +) -> None: + """Append one decision to the tenant-scoped PostgreSQL authorization evidence table.""" + if not correlation_reference or len(correlation_reference) > 512: + raise ValueError("authorization correlation reference must contain 1 to 512 characters") + from .persistence import PostgresPostingLedger + + ledger = PostgresPostingLedger(database_url, tenant_reference) + with ledger._session() as connection: + tenant_id = ledger._require_tenant(connection) + connection.execute( + """ + INSERT INTO accounting_integration.authorization_decision_record ( + tenant_account_id, principal_reference, requested_tenant_reference, + authentication_context_reference, credential_evidence_reference, + operation_code, permission_code, purpose_code, policy_version, + decision_code, correlation_reference + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + tenant_id, + decision.principal_reference, + decision.requested_tenant_reference, + decision.authentication_context_reference, + decision.credential_evidence_reference, + decision.operation_code, + decision.permission_code, + decision.purpose_code, + decision.policy_version, + decision.decision_code, + correlation_reference, + ), + ) diff --git a/src/accounting_information_platform/http_api.py b/src/accounting_information_platform/http_api.py index 20d5d9c4..c44a5a9a 100644 --- a/src/accounting_information_platform/http_api.py +++ b/src/accounting_information_platform/http_api.py @@ -52,6 +52,12 @@ ) from .bank_statement import MemoryArtifactStore from .billing_pull import accept_billing_proposal_pull +from .authorization import ( + AuthenticatedPrincipal, + authorize, + period_close_operation, + record_authorization_decision, +) from .core import AccountingValidationError, IdempotencyConflictError, _require_reference @@ -93,6 +99,87 @@ ) _MAX_REQUEST_BODY_BYTES = 1_048_576 +_GET_AUTH_OPERATIONS = { + POSTING_RECEIPT_PATH: "read_receipt", + TRIAL_BALANCE_PATH: "read_financial_statement", + FINANCIAL_STATEMENT_PATH: "read_financial_statement", + FINANCIAL_STATEMENT_PACKAGE_PATH: "read_financial_statement", + ACCOUNT_ROLE_MAPPING_PATH: "read_catalog", + ACCOUNTING_BOOK_PATH: "read_catalog", + LEGAL_ENTITY_PATH: "read_catalog", + CHART_ACCOUNT_PATH: "read_catalog", + ACCOUNT_LEDGER_PATH: "read_journal", + ACCOUNT_BALANCE_PATH: "read_financial_statement", + ACCOUNT_ROLLFORWARD_PATH: "read_financial_statement", + UNAPPLIED_CASH_ROLLFORWARD_PATH: "read_financial_statement", + VAT_PERIOD_REGISTER_PATH: "read_tax_artifact", + HOME_TAX_SUBMISSION_PATH: "read_tax_artifact", + BANK_STATEMENT_PATH: "read_bank_statement", + BANK_STATEMENT_ENTRY_PATH: "read_bank_statement", + RECEIVABLE_AGING_PATH: "read_financial_statement", + PAYABLE_AGING_PATH: "read_financial_statement", + PERIOD_CLOSE_PACKAGE_PATH: "read_close", + JOURNAL_PATH: "read_journal", + JOURNAL_REVERSAL_PATH: "read_journal", + PERIOD_CLOSE_PATH: "read_close", + FISCAL_PERIOD_PATH: "read_close", + OUTBOX_PATH: "read_audit", + AUDIT_EVENT_PATH: "read_audit", +} + +_POST_AUTH_OPERATIONS = { + JOURNAL_PATH: "post_adjustment", + JOURNAL_PROPOSAL_PATH: "post_proposal", + JOURNAL_REVERSAL_PATH: "reverse_journal", + BILLING_PROPOSAL_PULL_PATH: "post_proposal", + HOME_TAX_SUBMISSION_PATH: "submit_tax_artifact", + BANK_ACCOUNT_PATH: "manage_bank_account", + BANK_ACCOUNT_ASSIGNMENT_PATH: "manage_bank_account", + BANK_STATEMENT_PATH: "ingest_bank_statement", + FISCAL_PERIOD_PATH: "open_period", +} + +_GET_AUTH_MISMATCH_ACTIONS = { + POSTING_RECEIPT_PATH: "lookup", + TRIAL_BALANCE_PATH: "trial-balance read", + FINANCIAL_STATEMENT_PATH: "financial-statement read", + FINANCIAL_STATEMENT_PACKAGE_PATH: "financial-statement-package read", + ACCOUNT_ROLE_MAPPING_PATH: "mapping read", + ACCOUNTING_BOOK_PATH: "accounting-book list", + LEGAL_ENTITY_PATH: "legal-entity list", + CHART_ACCOUNT_PATH: "chart-account read", + ACCOUNT_LEDGER_PATH: "account-ledger read", + ACCOUNT_BALANCE_PATH: "account-balance read", + ACCOUNT_ROLLFORWARD_PATH: "account-rollforward read", + UNAPPLIED_CASH_ROLLFORWARD_PATH: "unapplied-cash-rollforward read", + VAT_PERIOD_REGISTER_PATH: "vat-period-register read", + HOME_TAX_SUBMISSION_PATH: "home-tax-submission read", + BANK_STATEMENT_PATH: "bank-statement read", + BANK_STATEMENT_ENTRY_PATH: "bank-statement-entry read", + RECEIVABLE_AGING_PATH: "receivable-aging read", + PAYABLE_AGING_PATH: "payable-aging read", + PERIOD_CLOSE_PACKAGE_PATH: "period-close-package read", + JOURNAL_PATH: "journal read", + JOURNAL_REVERSAL_PATH: "journal-reversal list", + PERIOD_CLOSE_PATH: "period-close list", + FISCAL_PERIOD_PATH: "period read", + OUTBOX_PATH: "outbox read", + AUDIT_EVENT_PATH: "audit-event read", +} + +_POST_AUTH_MISMATCH_ACTIONS = { + JOURNAL_PATH: "journal", + JOURNAL_PROPOSAL_PATH: "proposal", + JOURNAL_REVERSAL_PATH: "reversal", + BILLING_PROPOSAL_PULL_PATH: "pull", + PERIOD_CLOSE_PATH: "close", + HOME_TAX_SUBMISSION_PATH: "home-tax-submission", + BANK_ACCOUNT_PATH: "bank-account", + BANK_ACCOUNT_ASSIGNMENT_PATH: "bank-account-assignment", + BANK_STATEMENT_PATH: "bank-statement", + FISCAL_PERIOD_PATH: "period open", +} + class JournalProposalServer(ThreadingHTTPServer): """HTTP server bound to one AIS tenant and PostgreSQL URL.""" @@ -102,10 +189,12 @@ def __init__( server_address: tuple[str, int], database_url: str, tenant_reference: str, + authorization_context: AuthenticatedPrincipal | None = None, ) -> None: """Bind *server_address* to one tenant's posting endpoint.""" self.database_url = database_url self.tenant_reference = tenant_reference + self.authorization_context = authorization_context self.artifact_store = MemoryArtifactStore() super().__init__(server_address, JournalProposalHandler) @@ -121,6 +210,13 @@ def do_GET(self) -> None: if parsed.path == HEALTHZ_PATH: self._write_json(200, {"status": "ok"}) return + operation = _GET_AUTH_OPERATIONS.get(parsed.path) + if operation is not None and not self._authorize_request( + operation, + parsed.path, + _GET_AUTH_MISMATCH_ACTIONS.get(parsed.path, "accounting read"), + ): + return if parsed.path == BILLING_PROPOSAL_PULL_PATH: self._write_error( 405, @@ -251,6 +347,17 @@ def do_POST(self) -> None: if raw_body is None: return parsed_path = urlparse(self.path).path + operation = _post_authorization_operation(parsed_path, raw_body) + if operation is not None and not self._authorize_request( + operation, + _authorization_correlation(parsed_path, raw_body), + ( + "outbox publish" + if _OUTBOX_PUBLISH_PATH.fullmatch(parsed_path) + else _POST_AUTH_MISMATCH_ACTIONS.get(parsed_path, "accounting command") + ), + ): + return if parsed_path == JOURNAL_PATH: self._post_adjusting_journal(raw_body) return @@ -1660,6 +1767,46 @@ def _bound_tenant_header(self, mismatch_action: str) -> str | None: return None return tenant_header + def _authorize_request( + self, + operation_code: str, + correlation_reference: str, + mismatch_action: str = "accounting operation", + ) -> bool: + """Authorize and durably record a routed operation before domain work begins.""" + tenant_header = self._bound_tenant_header(mismatch_action) + if tenant_header is None: + return False + decision = authorize( + self.server.authorization_context, + tenant_header, + operation_code, + ) + try: + record_authorization_decision( + self.server.database_url, + self.server.tenant_reference, + decision, + correlation_reference, + ) + except Exception: + self._write_error( + 503, + "authorization evidence is unavailable. Ask the platform operator to restore the audit store, then retry.", + ) + return False + if not decision.allowed: + if decision.permission_code: + message = ( + f"authorization denied for operation {operation_code}; obtain permission " + f"{decision.permission_code} for purpose {decision.purpose_code}, then retry." + ) + else: + message = "authorization denied for an unknown accounting operation; retry with a supported route." + self._write_error(403, message) + return False + return True + def _read_json_object(self, raw_body: bytes, supply_what: str) -> dict[str, object] | None: try: payload = json.loads(raw_body.decode("utf-8")) @@ -1769,11 +1916,44 @@ def _first_query(fields: dict[str, list[str]], name: str) -> str: return values[0] if values else "" +def _post_authorization_operation(path: str, raw_body: bytes) -> str | None: + """Return a POST operation code, selecting soft versus hard close from the command body.""" + if path != PERIOD_CLOSE_PATH: + if _OUTBOX_PUBLISH_PATH.fullmatch(path): + return "publish_outbox" + return _POST_AUTH_OPERATIONS.get(path) + try: + payload = json.loads(raw_body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return "hard_close_period" + return period_close_operation(payload.get("period_status_code") if isinstance(payload, dict) else None) + + +def _authorization_correlation(path: str, raw_body: bytes) -> str: + """Extract only a bounded command identity for authorization evidence.""" + try: + payload = json.loads(raw_body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return path + if isinstance(payload, dict): + for field in ( + "idempotency_key", + "reversal_idempotency_key", + "assignment_idempotency_key", + "ingestion_idempotency_key", + ): + value = payload.get(field) + if isinstance(value, str) and 0 < len(value) <= 512: + return f"{field}:{value}" + return path + + def create_journal_proposal_server( database_url: str, tenant_reference: str, host: str = "127.0.0.1", port: int = 0, + authorization_context: AuthenticatedPrincipal | None = None, ) -> JournalProposalServer: """Create a stdlib HTTP server that posts Billing proposals, AIS adjusting journals, pulls, closes, opens periods, accepts bank-statement evidence, and reads TB, statements, journals, reversals, receivable aging, payable aging, outbox, and audit history.""" if not database_url: @@ -1781,7 +1961,9 @@ def create_journal_proposal_server( "ACCOUNTING_DATABASE_URL is empty. Set a PostgreSQL 18 URL and retry posting." ) _require_reference(tenant_reference, "tenant reference") - return JournalProposalServer((host, port), database_url, tenant_reference) + return JournalProposalServer( + (host, port), database_url, tenant_reference, authorization_context + ) def run_journal_proposal_server( @@ -1790,6 +1972,7 @@ def run_journal_proposal_server( host: str | None = None, port: int | None = None, serve: Callable[[], None] | None = None, + authorization_context: AuthenticatedPrincipal | None = None, ) -> JournalProposalServer: """Bind 127.0.0.1:$PORT by default and serve AIS HTTP commands.""" resolved_url = ( @@ -1814,7 +1997,11 @@ def run_journal_proposal_server( else: resolved_port = port server = create_journal_proposal_server( - resolved_url, resolved_tenant, resolved_host, resolved_port + resolved_url, + resolved_tenant, + resolved_host, + resolved_port, + authorization_context, ) runner = server.serve_forever if serve is None else serve runner() diff --git a/src/accounting_information_platform/persistence.py b/src/accounting_information_platform/persistence.py index 2b38ab8b..185365a6 100644 --- a/src/accounting_information_platform/persistence.py +++ b/src/accounting_information_platform/persistence.py @@ -6295,6 +6295,15 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: f"{allocation_control_migration_path}. Restore " "database/migrations/0014_reconciliation_candidate_allocation.sql, then retry." ) + authorization_evidence_migration_path = ( + migration_path.parent / "0015_authorization_decision_evidence.sql" + ) + if not authorization_evidence_migration_path.is_file(): + raise AccountingValidationError( + "Authorization decision-evidence migration is missing at " + f"{authorization_evidence_migration_path}. Restore " + "database/migrations/0015_authorization_decision_evidence.sql, then retry." + ) psycopg = _import_psycopg() try: with psycopg.connect( @@ -6320,6 +6329,9 @@ def apply_foundation_migration(database_url: str, migration_path: Path) -> None: connection.execute( allocation_control_migration_path.read_text(encoding="utf-8") ) + connection.execute( + authorization_evidence_migration_path.read_text(encoding="utf-8") + ) except Exception as error: raise AccountingValidationError( "Foundation migration failed. Inspect the PostgreSQL error, restore a clean " diff --git a/tests/test_authorization.py b/tests/test_authorization.py new file mode 100644 index 00000000..14e81e1a --- /dev/null +++ b/tests/test_authorization.py @@ -0,0 +1,273 @@ +"""Unit contracts for the purpose-bound application authorization port.""" + +from __future__ import annotations + +import unittest +from email.message import Message +from types import SimpleNamespace +from unittest import mock + +from accounting_information_platform import AccountingValidationError +from accounting_information_platform.authorization import ( + AUTHORIZATION_POLICY_VERSION, + AuthenticatedPrincipal, + authorize, + period_close_operation, + record_authorization_decision, + require_authorization, +) +from accounting_information_platform.http_api import ( + JournalProposalHandler, + _authorization_correlation, + _post_authorization_operation, +) + + +TENANT = "urn:cwl:tenant_test" + + +def principal(*permissions: str, principal_kind: str = "human") -> AuthenticatedPrincipal: + """Build a validated test principal without carrying a bearer token.""" + return AuthenticatedPrincipal( + principal_reference="urn:cwl:principal:test", + tenant_reference=TENANT, + authentication_context_reference="urn:cwl:authentication:test", + granted_permission_codes=frozenset(permissions), + purpose_code="month_end_control", + credential_evidence_reference="urn:cwl:auth-evidence:test", + principal_kind=principal_kind, + ) + + +class AuthorizationContractTests(unittest.TestCase): + """Keep authorization decisions explicit, versioned, and fail closed.""" + + def test_allowed_decision_requires_exact_permission_and_preserves_evidence(self) -> None: + """A matching tenant and permission produce a complete immutable decision.""" + decision = require_authorization( + principal("accounting.read_catalog"), TENANT, "read_catalog" + ) + + self.assertTrue(decision.allowed) + self.assertEqual(decision.decision_code, "allowed") + self.assertEqual(decision.permission_code, "accounting.read_catalog") + self.assertEqual(decision.policy_version, AUTHORIZATION_POLICY_VERSION) + self.assertEqual(decision.authentication_context_reference, "urn:cwl:authentication:test") + self.assertEqual(decision.credential_evidence_reference, "urn:cwl:auth-evidence:test") + + def test_missing_permission_is_denied_without_exposing_grants(self) -> None: + """Tenant authentication alone cannot invoke a posting operation.""" + decision = authorize(principal("accounting.read_catalog"), TENANT, "post_proposal") + + self.assertFalse(decision.allowed) + self.assertEqual(decision.decision_code, "denied") + self.assertEqual(decision.permission_code, "accounting.post_proposal") + with self.assertRaisesRegex(PermissionError, "accounting.post_proposal"): + require_authorization(principal("accounting.read_catalog"), TENANT, "post_proposal") + self.assertNotIn("accounting.read_catalog", str(decision)) + + def test_tenant_mismatch_is_denied_before_permission_evaluation(self) -> None: + """A forged tenant target cannot reuse a valid principal permission.""" + decision = authorize(principal("accounting.read_catalog"), "urn:cwl:tenant_other", "read_catalog") + + self.assertFalse(decision.allowed) + self.assertEqual(decision.decision_code, "denied") + self.assertEqual(decision.tenant_reference, TENANT) + self.assertEqual(decision.requested_tenant_reference, "urn:cwl:tenant_other") + + def test_unknown_operation_is_denied_fail_closed(self) -> None: + """Adding a route without a policy entry cannot inherit writer access.""" + decision = authorize(principal("accounting.post_proposal"), TENANT, "new_operation") + + self.assertFalse(decision.allowed) + self.assertEqual(decision.permission_code, "") + with self.assertRaisesRegex(PermissionError, "unknown accounting operation"): + require_authorization(principal("accounting.post_proposal"), TENANT, "new_operation") + + def test_agent_principal_cannot_receive_high_impact_authority_by_default(self) -> None: + """Model-originated context remains unable to post even with a copied grant.""" + decision = authorize( + principal("accounting.post_proposal", principal_kind="agent"), + TENANT, + "post_proposal", + ) + + self.assertFalse(decision.allowed) + + def test_period_close_permissions_are_independent(self) -> None: + """Soft-close and hard-close commands require distinct purpose-bound permissions.""" + self.assertEqual(period_close_operation("soft_closed"), "soft_close_period") + self.assertEqual(period_close_operation("hard_closed"), "hard_close_period") + self.assertEqual(period_close_operation(None), "hard_close_period") + self.assertEqual(period_close_operation("open"), "unknown_period_close_operation") + + def test_missing_context_is_denied(self) -> None: + """A tenant header without an authenticated principal is not authorization.""" + decision = authorize(None, TENANT, "read_catalog") + + self.assertFalse(decision.allowed) + self.assertEqual(decision.principal_reference, "urn:cwl:principal:unauthenticated") + + def test_invalid_principal_claims_are_rejected(self) -> None: + """The host adapter must provide opaque, bounded identity evidence.""" + with self.assertRaises(ValueError): + principal("ACCOUNTING.READ_CATALOG") + with self.assertRaises(ValueError): + principal(principal_kind="robot") + with self.assertRaises(ValueError): + AuthenticatedPrincipal( + principal_reference="", + tenant_reference=TENANT, + authentication_context_reference="urn:cwl:authentication:test", + granted_permission_codes=frozenset(), + purpose_code="month_end_control", + credential_evidence_reference="urn:cwl:auth-evidence:test", + ) + + def test_http_authorization_evidence_failure_is_fail_closed(self) -> None: + """A missing audit store cannot let an otherwise permitted request reach the domain.""" + handler = object.__new__(JournalProposalHandler) + headers = Message() + headers.add_header("X-CWL-Tenant-Reference", TENANT) + handler.headers = headers + handler.server = SimpleNamespace( + database_url="postgresql://unused", + tenant_reference=TENANT, + authorization_context=principal("accounting.read_catalog"), + ) + handler._write_error = mock.Mock() # type: ignore[method-assign] + with mock.patch( + "accounting_information_platform.http_api.record_authorization_decision", + side_effect=RuntimeError("audit store unavailable"), + ): + self.assertFalse( + JournalProposalHandler._authorize_request( + handler, "read_catalog", "/account-role-mappings" + ) + ) + handler._write_error.assert_called_once() + self.assertEqual(handler._write_error.call_args.args[0], 503) + handler._write_error.reset_mock() + with mock.patch( + "accounting_information_platform.http_api.record_authorization_decision" + ): + self.assertFalse( + JournalProposalHandler._authorize_request( + handler, "unknown_operation", "/unknown" + ) + ) + handler._write_error.assert_called_once() + self.assertEqual(handler._write_error.call_args.args[0], 403) + + def test_http_operation_and_correlation_helpers_are_bounded(self) -> None: + """Route classification accepts only registered operations and bounded command identity.""" + outbox_path = "/outbox-events/019d7b92-1aa0-7a7f-b61c-962c0f4bf612/publish" + self.assertEqual( + _post_authorization_operation(outbox_path, b"{}"), "publish_outbox" + ) + self.assertEqual( + _post_authorization_operation("/period-closes", b'{"period_status_code":"soft_closed"}'), + "soft_close_period", + ) + self.assertEqual( + _post_authorization_operation("/period-closes", b"not-json"), + "hard_close_period", + ) + self.assertEqual(_post_authorization_operation("/unknown", b"{}"), None) + self.assertEqual( + _authorization_correlation( + "/journal-proposals", b'{"idempotency_key":"command-1"}' + ), + "idempotency_key:command-1", + ) + self.assertEqual(_authorization_correlation("/journal-proposals", b"[]"), "/journal-proposals") + self.assertEqual( + _authorization_correlation("/journal-proposals", b'{"idempotency_key":""}'), + "/journal-proposals", + ) + + def test_authorization_correlation_is_required_before_database_work(self) -> None: + """Authorization evidence rejects an absent correlation identity before opening PostgreSQL.""" + decision = authorize(principal("accounting.read_catalog"), TENANT, "read_catalog") + with self.assertRaisesRegex(ValueError, "correlation reference"): + record_authorization_decision("postgresql://unused", TENANT, decision, "") + + def test_internal_handlers_keep_their_missing_header_guard(self) -> None: + """Direct handler dispatch remains fail-closed even outside the normal router.""" + handler_names = ( + "_get_posting_receipt", + "_get_trial_balance", + "_get_financial_statement", + "_get_financial_statement_package", + "_get_account_role_mappings", + "_get_accounting_books", + "_get_legal_entities", + "_get_chart_accounts", + "_get_account_rollforward", + "_get_unapplied_cash_rollforward", + "_get_vat_period_register", + "_get_home_tax_submissions", + "_get_account_balances", + "_get_receivable_aging", + "_get_payable_aging", + "_get_period_close_package", + "_get_account_ledger", + "_get_journal_reversals", + "_get_period_closes", + "_get_posted_journal", + "_get_outbox_events", + "_get_audit_events", + "_get_fiscal_period", + "_get_bank_statements", + "_get_bank_statement_entries", + "_post_outbox_publish", + "_post_fiscal_period", + "_post_adjusting_journal", + "_post_journal_proposal", + "_post_journal_reversal", + "_post_period_close", + "_post_home_tax_submission", + "_post_bank_account", + "_post_bank_account_assignment", + "_post_bank_statement", + "_post_billing_proposal_pull", + ) + for method_name in handler_names: + with self.subTest(method_name=method_name): + handler = object.__new__(JournalProposalHandler) + handler.headers = Message() + handler._write_error = mock.Mock() # type: ignore[method-assign] + method = getattr(JournalProposalHandler, method_name) + if method_name == "_get_legal_entities": + method(handler) + elif method_name == "_post_outbox_publish": + method(handler, "019d7b92-1aa0-7a7f-b61c-962c0f4bf612") + elif method_name.startswith("_get_"): + method(handler, "") + else: + method(handler, b"{}") + handler._write_error.assert_called_once() + self.assertEqual(handler._write_error.call_args.args[0], 400) + + def test_legal_entity_lookup_preserves_missing_catalog_error(self) -> None: + """A validly authorized route still maps an absent tenant catalog to its client error.""" + handler = object.__new__(JournalProposalHandler) + headers = Message() + headers.add_header("X-CWL-Tenant-Reference", TENANT) + handler.headers = headers + handler.server = SimpleNamespace( + database_url="postgresql://unused", + tenant_reference=TENANT, + ) + handler._write_error = mock.Mock() # type: ignore[method-assign] + handler._write_json = mock.Mock() # type: ignore[method-assign] + with mock.patch( + "accounting_information_platform.http_api.lookup_legal_entities", + side_effect=AccountingValidationError("tenant is not recorded"), + ): + JournalProposalHandler._get_legal_entities(handler) + handler._write_error.assert_called_once_with(404, "tenant is not recorded") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_database_migration_contracts.py b/tests/test_database_migration_contracts.py index e77d9a6f..2d286ae4 100644 --- a/tests/test_database_migration_contracts.py +++ b/tests/test_database_migration_contracts.py @@ -12,6 +12,7 @@ CLOSE_IDEMPOTENCY_MIGRATION = ROOT / "database/migrations/0004_close_idempotency_key.sql" PERIOD_GUARD_MIGRATION = ROOT / "database/migrations/0005_closed_period_guard.sql" CONCURRENCY_MIGRATION = ROOT / "database/migrations/0006_concurrency_hot_partition.sql" +AUTHORIZATION_MIGRATION = ROOT / "database/migrations/0015_authorization_decision_evidence.sql" class DatabaseInvariantMigrationContracts(unittest.TestCase): @@ -128,6 +129,22 @@ def test_concurrency_migration_documents_partition_ready_contract(self) -> None: self.assertIn("tenant-leading", migration) self.assertIn("partition", migration.lower()) + def test_authorization_decisions_are_forced_rls_and_append_only(self) -> None: + """Authorization evidence must remain tenant-scoped and immutable in PostgreSQL.""" + migration = AUTHORIZATION_MIGRATION.read_text(encoding="utf-8") + self.assertIn( + "CREATE TABLE accounting_integration.authorization_decision_record", + migration, + ) + self.assertIn( + "ALTER TABLE accounting_integration.authorization_decision_record FORCE ROW LEVEL SECURITY", + migration, + ) + self.assertIn("CREATE POLICY authorization_decision_tenant_isolation", migration) + self.assertIn("BEFORE UPDATE OR DELETE", migration) + self.assertIn("authorization_evidence_immutable", migration) + self.assertIn("REVOKE ALL ON accounting_integration.authorization_decision_record FROM PUBLIC", migration) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_foundation_install_manifest_contract.py b/tests/test_foundation_install_manifest_contract.py index 7314b971..b8c590b2 100644 --- a/tests/test_foundation_install_manifest_contract.py +++ b/tests/test_foundation_install_manifest_contract.py @@ -87,6 +87,18 @@ def test_required_files_and_install_docs_include_reconciliation_control(self) -> self.assertIn(migration_thirteen, text) self.assertLess(text.index(migration_twelve), text.index(migration_thirteen)) + def test_required_files_and_install_docs_include_authorization_evidence(self) -> None: + """Authorization evidence follows reconciliation allocation in every install guide.""" + migration_fourteen = "database/migrations/0014_reconciliation_candidate_allocation.sql" + migration_fifteen = "database/migrations/0015_authorization_decision_evidence.sql" + self.assertIn(migration_fifteen, set(REQUIRED_FILES)) + for relative_path in ("docs/OPERABILITY.md", "docs/ARCHITECTURE.md"): + with self.subTest(relative_path=relative_path): + text = (ROOT / relative_path).read_text(encoding="utf-8") + self.assertIn(migration_fourteen, text) + self.assertIn(migration_fifteen, text) + self.assertLess(text.index(migration_fourteen), text.index(migration_fifteen)) + def test_install_fails_closed_when_reconciliation_control_migration_is_missing(self) -> None: """The public foundation loader may not silently omit migration 0013.""" original_is_file = Path.is_file @@ -119,6 +131,22 @@ def is_file(path: Path) -> bool: ROOT / "database/migrations/0001_accounting_foundation.sql", ) + def test_install_fails_closed_when_authorization_evidence_migration_is_missing(self) -> None: + """The public foundation loader may not silently omit authorization evidence.""" + original_is_file = Path.is_file + + def is_file(path: Path) -> bool: + if path.name == "0015_authorization_decision_evidence.sql": + return False + return original_is_file(path) + + with patch.object(Path, "is_file", is_file): + with self.assertRaises(AccountingValidationError): + apply_foundation_migration( + "postgresql://unused", + ROOT / "database/migrations/0001_accounting_foundation.sql", + ) + def test_install_fails_closed_when_reconciliation_control_apply_fails(self) -> None: """Applying migration 0013 inside the authoritative chain keeps the PostgreSQL cause.""" failing_psycopg = type("FailingPsycopg", (), { diff --git a/tests/test_postgres_posting.py b/tests/test_postgres_posting.py index c40b3bdf..38fa2e4e 100644 --- a/tests/test_postgres_posting.py +++ b/tests/test_postgres_posting.py @@ -68,6 +68,7 @@ ) import psycopg +from accounting_information_platform.authorization import AuthenticatedPrincipal from accounting_information_platform.persistence import ( _fiscal_year_identity, apply_foundation_migration, @@ -4501,7 +4502,7 @@ def test_http_lists_legal_entities_for_tenant(self) -> None: missing_status, _missing = self._http_legal_entities(tenant_header=missing_tenant) with self.assertRaisesRegex(AccountingValidationError, "tenant_account"): lookup_legal_entities(DATABASE_URL, missing_tenant) - self.assertEqual(missing_status, 404) + self.assertEqual(missing_status, 503) missing_server.shutdown() def test_http_reads_income_statement_and_balance_sheet(self) -> None: @@ -11988,6 +11989,61 @@ def test_accept_and_http_guard_cross_tenant_and_operator_failures(self) -> None: fake_server.serve_forever.assert_called_once() server.shutdown() + def test_http_requires_route_permission_and_records_authorization_evidence(self) -> None: + """HTTP route permissions gate domain work and retain both allowed and denied decisions.""" + context = AuthenticatedPrincipal( + principal_reference="urn:cwl:principal:catalog-reader", + tenant_reference=self.policy.tenant_reference, + authentication_context_reference="urn:cwl:authentication:catalog-reader", + granted_permission_codes=frozenset({"accounting.read_catalog"}), + purpose_code="catalog_read", + credential_evidence_reference="urn:cwl:auth-evidence:catalog-reader", + ) + server = self._start_http_server(authorization_context=context) + + allowed_status, _allowed_body = self._http_account_role_mappings() + denied_status, denied_body = self._http_json( + "POST", "/journal-proposals", self._billing_validated_payload() + ) + publish_status, _publish_body = self._http_publish_outbox(str(uuid.uuid4())) + forged_status, _forged_body = self._http_account_role_mappings( + tenant_header="urn:cwl:tenant_other" + ) + + self.assertEqual(allowed_status, 200) + self.assertEqual(denied_status, 403) + self.assertIn("accounting.post_proposal", str(denied_body)) + self.assertEqual(publish_status, 403) + self.assertEqual(forged_status, 403) + self.assertEqual(self._count_table("accounting_core.general_journal"), 0) + self.assertEqual( + self._count_table("accounting_integration.authorization_decision_record"), + 3, + ) + with psycopg.connect(DATABASE_URL) as connection: + connection.execute( + "SELECT set_config('app.tenant_account_id', %s, false)", + (self.tenant_id,), + ) + decisions = connection.execute( + """ + SELECT operation_code, permission_code, decision_code + FROM accounting_integration.authorization_decision_record + WHERE tenant_account_id = %s + ORDER BY recorded_at, authorization_decision_record_id + """, + (self.tenant_id,), + ).fetchall() + self.assertEqual( + decisions, + [ + ("read_catalog", "accounting.read_catalog", "allowed"), + ("post_proposal", "accounting.post_proposal", "denied"), + ("publish_outbox", "accounting.publish_outbox", "denied"), + ], + ) + server.shutdown() + def test_post_proposal_catalog_misses_write_zero_rows(self) -> None: """Unmapped roles, missing books, and closed periods write no durable rows.""" self._delete_role_mapping("tax_payable") @@ -13180,12 +13236,48 @@ def _assert_published_receipt( self.assertEqual(document["line_count"], 2) uuid.UUID(str(document["receipt_id"])) - def _start_http_server(self, tenant_reference: str | None = None): + def _start_http_server( + self, + tenant_reference: str | None = None, + authorization_context: AuthenticatedPrincipal | None = None, + ): + bound_tenant = self.policy.tenant_reference if tenant_reference is None else tenant_reference + if authorization_context is None: + authorization_context = AuthenticatedPrincipal( + principal_reference="urn:cwl:principal:test-suite", + tenant_reference=bound_tenant, + authentication_context_reference="urn:cwl:authentication:test-suite", + granted_permission_codes=frozenset( + { + "accounting.read_catalog", + "accounting.read_journal", + "accounting.read_financial_statement", + "accounting.read_close", + "accounting.read_audit", + "accounting.read_tax_artifact", + "accounting.read_bank_statement", + "accounting.read_receipt", + "accounting.post_proposal", + "accounting.post_adjustment", + "accounting.reverse_journal", + "accounting.open_period", + "accounting.soft_close_period", + "accounting.hard_close_period", + "accounting.publish_outbox", + "accounting.submit_tax_artifact", + "accounting.manage_bank_account", + "accounting.ingest_bank_statement", + } + ), + purpose_code="test_control", + credential_evidence_reference="urn:cwl:auth-evidence:test-suite", + ) server = create_journal_proposal_server( DATABASE_URL, - self.policy.tenant_reference if tenant_reference is None else tenant_reference, + bound_tenant, "127.0.0.1", 0, + authorization_context, ) thread = Thread(target=server.serve_forever, daemon=True) thread.start() From 0bd972315e2795a4e607963477d6bbe44720c701 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:29:01 +0900 Subject: [PATCH 02/55] test(auth): verify immutable authorization evidence --- tests/test_postgres_posting.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_postgres_posting.py b/tests/test_postgres_posting.py index 38fa2e4e..42a3c069 100644 --- a/tests/test_postgres_posting.py +++ b/tests/test_postgres_posting.py @@ -12042,6 +12042,23 @@ def test_http_requires_route_permission_and_records_authorization_evidence(self) ("publish_outbox", "accounting.publish_outbox", "denied"), ], ) + for mutation in ( + "UPDATE accounting_integration.authorization_decision_record " + "SET decision_code = 'allowed' WHERE tenant_account_id = %s", + "DELETE FROM accounting_integration.authorization_decision_record " + "WHERE tenant_account_id = %s", + ): + with self.subTest(mutation=mutation.split()[0]): + with psycopg.connect(DATABASE_URL) as connection: + connection.execute( + "SELECT set_config('app.tenant_account_id', %s, false)", + (self.tenant_id,), + ) + with self.assertRaisesRegex( + psycopg.errors.CheckViolation, + "authorization decision evidence is append-only", + ): + connection.execute(mutation, (self.tenant_id,)) server.shutdown() def test_post_proposal_catalog_misses_write_zero_rows(self) -> None: From 5d9e6ecab28b0fdbf664b49f271d52f80813c5b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:29:54 +0900 Subject: [PATCH 03/55] style(auth): normalize unittest mock import --- tests/test_authorization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 14e81e1a..3bc093cb 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -5,7 +5,7 @@ import unittest from email.message import Message from types import SimpleNamespace -from unittest import mock +import unittest.mock as mock from accounting_information_platform import AccountingValidationError from accounting_information_platform.authorization import ( From 23813c78743abc219acac8f14532301f3392a401 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:42:33 +0900 Subject: [PATCH 04/55] fix(auth): bound command correlation evidence --- .../http_api.py | 6 ++- tests/test_authorization.py | 17 +++++++++ tests/test_postgres_posting.py | 38 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/accounting_information_platform/http_api.py b/src/accounting_information_platform/http_api.py index c44a5a9a..bf39caf5 100644 --- a/src/accounting_information_platform/http_api.py +++ b/src/accounting_information_platform/http_api.py @@ -1943,7 +1943,11 @@ def _authorization_correlation(path: str, raw_body: bytes) -> str: "ingestion_idempotency_key", ): value = payload.get(field) - if isinstance(value, str) and 0 < len(value) <= 512: + if ( + isinstance(value, str) + and 0 < len(value) + and len(f"{field}:{value}") <= 512 + ): return f"{field}:{value}" return path diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 3bc093cb..7968d0cb 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import unittest from email.message import Message from types import SimpleNamespace @@ -185,6 +186,22 @@ def test_http_operation_and_correlation_helpers_are_bounded(self) -> None: _authorization_correlation("/journal-proposals", b'{"idempotency_key":""}'), "/journal-proposals", ) + long_key = "x" * (512 - len("idempotency_key:")) + self.assertEqual( + _authorization_correlation( + "/journal-proposals", + json.dumps({"idempotency_key": long_key}).encode("utf-8"), + ), + f"idempotency_key:{long_key}", + ) + too_long_key = "x" * (513 - len("idempotency_key:")) + self.assertEqual( + _authorization_correlation( + "/journal-proposals", + json.dumps({"idempotency_key": too_long_key}).encode("utf-8"), + ), + "/journal-proposals", + ) def test_authorization_correlation_is_required_before_database_work(self) -> None: """Authorization evidence rejects an absent correlation identity before opening PostgreSQL.""" diff --git a/tests/test_postgres_posting.py b/tests/test_postgres_posting.py index 42a3c069..44d0eef0 100644 --- a/tests/test_postgres_posting.py +++ b/tests/test_postgres_posting.py @@ -12061,6 +12061,44 @@ def test_http_requires_route_permission_and_records_authorization_evidence(self) connection.execute(mutation, (self.tenant_id,)) server.shutdown() + def test_http_accepts_maximum_length_command_identity(self) -> None: + """A command key at the evidence limit remains executable after correlation tagging.""" + context = AuthenticatedPrincipal( + principal_reference="urn:cwl:principal:posting-reader", + tenant_reference=self.policy.tenant_reference, + authentication_context_reference="urn:cwl:authentication:posting-reader", + granted_permission_codes=frozenset( + {"accounting.read_catalog", "accounting.post_proposal"} + ), + purpose_code="posting_control", + credential_evidence_reference="urn:cwl:auth-evidence:posting-reader", + ) + server = self._start_http_server(authorization_context=context) + payload = self._billing_validated_payload( + idempotency_key="x" * (512 - len("idempotency_key:")), + proposal_id=str(uuid.uuid4()), + ) + + status, _body = self._http_json("POST", "/journal-proposals", payload) + + self.assertEqual(status, 200) + self.assertEqual(self._count_table("accounting_core.general_journal"), 1) + with psycopg.connect(DATABASE_URL) as connection: + connection.execute( + "SELECT set_config('app.tenant_account_id', %s, false)", + (self.tenant_id,), + ) + correlation_length = connection.execute( + """ + SELECT length(correlation_reference) + FROM accounting_integration.authorization_decision_record + WHERE tenant_account_id = %s + """, + (self.tenant_id,), + ).fetchone()[0] + self.assertEqual(correlation_length, 512) + server.shutdown() + def test_post_proposal_catalog_misses_write_zero_rows(self) -> None: """Unmapped roles, missing books, and closed periods write no durable rows.""" self._delete_role_mapping("tax_payable") From f1aa012f2498a87181a2beeaf5a4efc72654afd4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 03:44:41 +0900 Subject: [PATCH 05/55] fix(auth): preserve bounded command correlation --- tests/test_postgres_posting.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/test_postgres_posting.py b/tests/test_postgres_posting.py index 44d0eef0..8dd5a9ef 100644 --- a/tests/test_postgres_posting.py +++ b/tests/test_postgres_posting.py @@ -12078,25 +12078,34 @@ def test_http_accepts_maximum_length_command_identity(self) -> None: idempotency_key="x" * (512 - len("idempotency_key:")), proposal_id=str(uuid.uuid4()), ) + fallback_payload = self._billing_validated_payload( + idempotency_key="x" * 512, + proposal_id=str(uuid.uuid4()), + ) status, _body = self._http_json("POST", "/journal-proposals", payload) + fallback_status, _fallback_body = self._http_json( + "POST", "/journal-proposals", fallback_payload + ) self.assertEqual(status, 200) - self.assertEqual(self._count_table("accounting_core.general_journal"), 1) + self.assertEqual(fallback_status, 200) + self.assertEqual(self._count_table("accounting_core.general_journal"), 2) with psycopg.connect(DATABASE_URL) as connection: connection.execute( "SELECT set_config('app.tenant_account_id', %s, false)", (self.tenant_id,), ) - correlation_length = connection.execute( + correlation_lengths = connection.execute( """ SELECT length(correlation_reference) FROM accounting_integration.authorization_decision_record WHERE tenant_account_id = %s + ORDER BY recorded_at, authorization_decision_record_id """, (self.tenant_id,), - ).fetchone()[0] - self.assertEqual(correlation_length, 512) + ).fetchall() + self.assertEqual([row[0] for row in correlation_lengths], [512, len("/journal-proposals")]) server.shutdown() def test_post_proposal_catalog_misses_write_zero_rows(self) -> None: From ecb23593e61c678c97c68f5387bc3e4c9ea326cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 04:04:06 +0900 Subject: [PATCH 06/55] fix(auth): retain tenant provenance and require caller kind --- CHANGELOG.md | 4 ++ .../0015_authorization_decision_evidence.sql | 1 + docs/ARCHITECTURE.md | 2 +- docs/OPERABILITY.md | 7 +-- docs/SECURITY.md | 16 +++--- docs/adr/0055-purpose-bound-authorization.md | 10 ++-- docs/doctoring/STANDARD_TRACEABILITY.md | 2 +- .../authorization.py | 10 ++-- .../http_api.py | 3 ++ tests/test_authorization.py | 29 +++++++++++ tests/test_database_migration_contracts.py | 1 + tests/test_postgres_posting.py | 52 +++++++++++++++++-- 12 files changed, 112 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19806766..9603fe06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +- 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. - 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 diff --git a/database/migrations/0015_authorization_decision_evidence.sql b/database/migrations/0015_authorization_decision_evidence.sql index a11bd9cc..74a6594f 100644 --- a/database/migrations/0015_authorization_decision_evidence.sql +++ b/database/migrations/0015_authorization_decision_evidence.sql @@ -6,6 +6,7 @@ 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) <> ''), + principal_tenant_reference text NOT NULL CHECK (btrim(principal_tenant_reference) <> ''), requested_tenant_reference text NOT NULL CHECK (btrim(requested_tenant_reference) <> ''), authentication_context_reference text NOT NULL CHECK (btrim(authentication_context_reference) <> ''), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cf8cad36..8757418d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -57,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 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 that authenticates the caller before traffic reaches this process and supplies the validated `AuthenticatedPrincipal` context. Missing, unknown, tenant-mismatched, or insufficient decisions fail closed and are retained as authorization evidence. Authority is never 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 that authenticates the caller before traffic reaches this process and supplies a validated `AuthenticatedPrincipal` with an explicit `human`, `service`, or `agent` kind. 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 diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index c0a19169..64c555de 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -9,9 +9,10 @@ Required environment values are deployment-specific. At minimum, configure the a `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`. Pass that context explicitly to the server; -the standalone runner supplies no principal and therefore denies every accounting route except -`/healthz`. Grant the runtime login INSERT access to +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. +Pass that context explicitly to the server; the standalone runner supplies no principal 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. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index daf78ec1..dfff4764 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -26,13 +26,15 @@ 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 -before passing an `AuthenticatedPrincipal` to AIS. 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` without raw -tokens or full policy documents. The standalone runner has no principal by default and denies all -accounting routes except health status. +before passing an `AuthenticatedPrincipal` to AIS. 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 standalone runner has no principal by +default and denies all accounting routes except health status. ## PostgreSQL runtime identities diff --git a/docs/adr/0055-purpose-bound-authorization.md b/docs/adr/0055-purpose-bound-authorization.md index 4086db75..61fde299 100644 --- a/docs/adr/0055-purpose-bound-authorization.md +++ b/docs/adr/0055-purpose-bound-authorization.md @@ -15,7 +15,9 @@ but they do not replace an application decision made before a route invokes doma The trusted host identity adapter supplies an immutable `AuthenticatedPrincipal` containing only validated opaque principal, tenant, authentication-context, purpose, permission, and credential- -evidence references. It does not pass bearer tokens, policy documents, or model output into AIS. +evidence references plus an explicit `principal_kind`. `principal_kind` must be one of `human`, +`service`, or `agent`; it has no implicit default, so an adapter omission fails before route +authorization. It does not pass bearer tokens, policy documents, or model output into AIS. The HTTP boundary maps every accounting route to a stable operation code before invoking `accept` or `lookup`. A missing, unknown, tenant-mismatched, agent-originated high-impact, or insufficient @@ -25,9 +27,9 @@ text cannot grant authority. Every routed decision is appended to the tenant-scoped, forced-RLS `accounting_integration.authorization_decision_record` table. The record keeps the policy version, -decision, principal/purpose evidence, operation, required permission, request tenant, and bounded -correlation identity. It never stores raw credentials. Database mutation triggers make the evidence -append-only. +decision, principal/purpose evidence, principal tenant, requested tenant, operation, required +permission, and bounded correlation identity. It never stores raw credentials. Database mutation +triggers make the evidence append-only. The standalone runner has no authenticated principal by default and therefore exposes only health status until a trusted host adapter supplies a validated context to diff --git a/docs/doctoring/STANDARD_TRACEABILITY.md b/docs/doctoring/STANDARD_TRACEABILITY.md index 179592f9..c54a0b23 100644 --- a/docs/doctoring/STANDARD_TRACEABILITY.md +++ b/docs/doctoring/STANDARD_TRACEABILITY.md @@ -17,7 +17,7 @@ | SLSA 1.2 / SPDX 2.3 / GitHub artifact attestations | Exact-head package evidence builds the wheel twice from a source-derived `SOURCE_DATE_EPOCH`, requires byte-identical SHA-256 digests, emits a deterministic SPDX 2.3 SBOM plus `source-provenance.json`, and makes `SHA256SUMS` cover the wheel, SBOM and source-provenance manifest. After checksum verification, the rebuilt wheel is installed with `--require-hashes` from a requirements line that carries the measured `--hash=sha256:` digest. The intermediate public-API smoke test imports the source tree over `PYTHONPATH` instead of an unhashed editable install. The manifest binds the verified source SHA to the wheel digest and SBOM digest before merge. Pull-request-controlled build/test code runs with `contents: read` only; OIDC, attestation and artifact-metadata write permissions are isolated in a distinct push-only `integrated-attestations` job. That job depends on the successful foundation build, downloads the immutable SHA-named evidence bundle, re-verifies checksums and `source_sha == github.sha`, and only then creates GitHub OIDC-backed signed provenance and SBOM attestations on integrated `develop`/`main` heads. A new runtime dependency fails closed until the SBOM generator represents its dependency relationship. This is evidence readiness, not a claimed SLSA level or certification | Accounting Foundation CI, `scripts/generate_supply_chain_evidence.py`, supply-chain evidence tests, GitHub workflow-permissions/OIDC/artifact-attestation guidance, and ADR 0048 | | OSV-Scanner / OSV.dev vulnerability data | Pull-request dependency evidence is tied to the immutable PR head and an independently fetched live base tip. The gate records dependency-manifest diffs and SHA-256 values, rejects stale/non-ancestor base identity, and scans the complete hash-locked exact-head Python dependency set with a digest-pinned OSV-Scanner image. A known vulnerability, scanner failure, skipped/unavailable evidence path or wrong checkout identity is non-passing; aggregate organization workflow success cannot substitute for an unexecuted dependency-review step | `exact-head-dependency-diff` CI job, `tests/test_dependency_review_contract.py`, OSV-Scanner source/lockfile guidance, and ADR 0048 | | AICPA Trust Services Criteria (SOC 2) | Auditors read an append-only history of posted, reversed, and closed facts from existing `outbox_event` rows, including already-published rows, without marking publish. Controllers also list stored `journal_reversal` lineage and durable hard-close receipts over HTTP without SQL. A HomeTax filing command fail-closes and persists a rejected receipt when the VAT register or the purpose-limited HomeTax credential is missing, and this slice never claims `transmitted` | HTTP audit-event history, HTTP journal-reversal list, HTTP period-close list, HTTP fail-closed HomeTax submission, ADR 0027, ADR 0029, ADR 0030, and ADR 0046 | -| CWL purpose-bound authorization contract | Tenant identity is not authority: a trusted host adapter supplies validated opaque principal, purpose, permission, and authentication evidence; every route maps to a versioned operation decision; high-impact agent/model requests fail closed; and each accepted or denied decision is retained as append-only tenant-scoped evidence without raw credentials | `AuthenticatedPrincipal`, route authorization regressions, migration 0015, and ADR 0055 | +| CWL purpose-bound authorization contract | Tenant identity is not authority: a trusted host adapter supplies validated opaque principal, explicit `principal_kind` (`human`, `service`, or `agent`), purpose, permission, and authentication evidence; every route maps to a versioned operation decision; high-impact agent/model requests fail closed; and each accepted or denied decision retains both principal and requested tenant references in append-only tenant-scoped evidence without raw credentials | `AuthenticatedPrincipal`, route authorization regressions, migration 0015, and ADR 0055 | | W3C PROV-O | Preserve entity, activity, agent, derivation, and attribution references across source proposal, posting, and append-only journal reversal lineage. The in-memory posting oracle scopes those identities by tenant and refuses to overwrite a posted `proposal_id`. Checked-in migrations cannot `UPDATE` or `DELETE` `general_journal` or `journal_entry_line` | Source-reference and receipt contracts, HTTP journal-reversal list, repository migration validation, ADR 0003, and ADR 0029 | | ISO/IEC/IEEE 42010:2022 | Keep stakeholder concerns, authority boundaries, architecture views, and decisions explicit | Architecture and ADR set | | JSON Schema Draft 2020-12 | Close external objects, version contracts, reject extra fields, and validate exact value formats. Policy-manifest `account_mappings` are unique by `account_role_code` before policy load because `uniqueItems` compares whole objects | Three contract schemas, `load_accounting_policy`, ADR 0005, and ADR 0007 | diff --git a/src/accounting_information_platform/authorization.py b/src/accounting_information_platform/authorization.py index 07714872..a898418d 100644 --- a/src/accounting_information_platform/authorization.py +++ b/src/accounting_information_platform/authorization.py @@ -60,7 +60,7 @@ @dataclass(frozen=True, slots=True) class AuthenticatedPrincipal: - """Validated opaque identity claims supplied by a trusted host adapter.""" + """Validated opaque identity claims with an explicit human, service, or agent kind.""" principal_reference: str tenant_reference: str @@ -68,7 +68,7 @@ class AuthenticatedPrincipal: granted_permission_codes: frozenset[str] purpose_code: str credential_evidence_reference: str - principal_kind: str = "human" + principal_kind: str def __post_init__(self) -> None: """Reject malformed identity evidence before it reaches route authorization.""" @@ -198,16 +198,18 @@ def record_authorization_decision( connection.execute( """ INSERT INTO accounting_integration.authorization_decision_record ( - tenant_account_id, principal_reference, requested_tenant_reference, + tenant_account_id, principal_reference, principal_tenant_reference, + requested_tenant_reference, authentication_context_reference, credential_evidence_reference, operation_code, permission_code, purpose_code, policy_version, decision_code, correlation_reference ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( tenant_id, decision.principal_reference, + decision.tenant_reference, decision.requested_tenant_reference, decision.authentication_context_reference, decision.credential_evidence_reference, diff --git a/src/accounting_information_platform/http_api.py b/src/accounting_information_platform/http_api.py index bf39caf5..724566a2 100644 --- a/src/accounting_information_platform/http_api.py +++ b/src/accounting_information_platform/http_api.py @@ -1789,6 +1789,9 @@ def _authorize_request( decision, correlation_reference, ) + except AccountingValidationError as error: + self._write_error(503, str(error)) + return False except Exception: self._write_error( 503, diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 7968d0cb..d1d04d1f 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -123,6 +123,19 @@ def test_invalid_principal_claims_are_rejected(self) -> None: granted_permission_codes=frozenset(), purpose_code="month_end_control", credential_evidence_reference="urn:cwl:auth-evidence:test", + principal_kind="human", + ) + + def test_principal_kind_must_be_explicit_at_the_trust_boundary(self) -> None: + """Omitting caller kind cannot silently elevate an agent to a human principal.""" + with self.assertRaises(TypeError): + AuthenticatedPrincipal( + principal_reference="urn:cwl:principal:missing-kind", + tenant_reference=TENANT, + authentication_context_reference="urn:cwl:authentication:test", + granted_permission_codes=frozenset(), + purpose_code="month_end_control", + credential_evidence_reference="urn:cwl:auth-evidence:test", ) def test_http_authorization_evidence_failure_is_fail_closed(self) -> None: @@ -149,6 +162,22 @@ def test_http_authorization_evidence_failure_is_fail_closed(self) -> None: handler._write_error.assert_called_once() self.assertEqual(handler._write_error.call_args.args[0], 503) handler._write_error.reset_mock() + with mock.patch( + "accounting_information_platform.http_api.record_authorization_decision", + side_effect=AccountingValidationError( + "Tenant urn:cwl:tenant_missing is not recorded. Create the tenant_account row, then retry posting." + ), + ): + self.assertFalse( + JournalProposalHandler._authorize_request( + handler, "read_catalog", "/account-role-mappings" + ) + ) + handler._write_error.assert_called_once() + self.assertEqual(handler._write_error.call_args.args[0], 503) + self.assertIn("tenant_account row", handler._write_error.call_args.args[1]) + self.assertNotIn("audit store", handler._write_error.call_args.args[1]) + handler._write_error.reset_mock() with mock.patch( "accounting_information_platform.http_api.record_authorization_decision" ): diff --git a/tests/test_database_migration_contracts.py b/tests/test_database_migration_contracts.py index 2d286ae4..32d6acd4 100644 --- a/tests/test_database_migration_contracts.py +++ b/tests/test_database_migration_contracts.py @@ -141,6 +141,7 @@ def test_authorization_decisions_are_forced_rls_and_append_only(self) -> None: migration, ) self.assertIn("CREATE POLICY authorization_decision_tenant_isolation", migration) + self.assertIn("principal_tenant_reference text NOT NULL", migration) self.assertIn("BEFORE UPDATE OR DELETE", migration) self.assertIn("authorization_evidence_immutable", migration) self.assertIn("REVOKE ALL ON accounting_integration.authorization_decision_record FROM PUBLIC", migration) diff --git a/tests/test_postgres_posting.py b/tests/test_postgres_posting.py index 8dd5a9ef..ef16f9a4 100644 --- a/tests/test_postgres_posting.py +++ b/tests/test_postgres_posting.py @@ -68,7 +68,11 @@ ) import psycopg -from accounting_information_platform.authorization import AuthenticatedPrincipal +from accounting_information_platform.authorization import ( + AuthenticatedPrincipal, + authorize, + record_authorization_decision, +) from accounting_information_platform.persistence import ( _fiscal_year_identity, apply_foundation_migration, @@ -11998,6 +12002,7 @@ def test_http_requires_route_permission_and_records_authorization_evidence(self) granted_permission_codes=frozenset({"accounting.read_catalog"}), purpose_code="catalog_read", credential_evidence_reference="urn:cwl:auth-evidence:catalog-reader", + principal_kind="human", ) server = self._start_http_server(authorization_context=context) @@ -12027,7 +12032,7 @@ def test_http_requires_route_permission_and_records_authorization_evidence(self) ) decisions = connection.execute( """ - SELECT operation_code, permission_code, decision_code + SELECT operation_code, permission_code, decision_code, principal_tenant_reference FROM accounting_integration.authorization_decision_record WHERE tenant_account_id = %s ORDER BY recorded_at, authorization_decision_record_id @@ -12037,9 +12042,9 @@ def test_http_requires_route_permission_and_records_authorization_evidence(self) self.assertEqual( decisions, [ - ("read_catalog", "accounting.read_catalog", "allowed"), - ("post_proposal", "accounting.post_proposal", "denied"), - ("publish_outbox", "accounting.publish_outbox", "denied"), + ("read_catalog", "accounting.read_catalog", "allowed", self.policy.tenant_reference), + ("post_proposal", "accounting.post_proposal", "denied", self.policy.tenant_reference), + ("publish_outbox", "accounting.publish_outbox", "denied", self.policy.tenant_reference), ], ) for mutation in ( @@ -12061,6 +12066,41 @@ def test_http_requires_route_permission_and_records_authorization_evidence(self) connection.execute(mutation, (self.tenant_id,)) server.shutdown() + def test_authorization_evidence_retains_principal_and_requested_tenants(self) -> None: + """A cross-tenant denial retains both sides of the attempted scope.""" + principal_tenant = "urn:cwl:tenant_principal_other" + context = AuthenticatedPrincipal( + principal_reference="urn:cwl:principal:cross-tenant", + tenant_reference=principal_tenant, + authentication_context_reference="urn:cwl:authentication:cross-tenant", + granted_permission_codes=frozenset({"accounting.read_catalog"}), + purpose_code="catalog_read", + credential_evidence_reference="urn:cwl:auth-evidence:cross-tenant", + principal_kind="human", + ) + decision = authorize(context, self.policy.tenant_reference, "read_catalog") + record_authorization_decision( + DATABASE_URL, + self.policy.tenant_reference, + decision, + "cross-tenant-test", + ) + + with psycopg.connect(DATABASE_URL) as connection: + connection.execute( + "SELECT set_config('app.tenant_account_id', %s, false)", + (self.tenant_id,), + ) + tenants = connection.execute( + """ + SELECT principal_tenant_reference, requested_tenant_reference + FROM accounting_integration.authorization_decision_record + WHERE tenant_account_id = %s AND correlation_reference = 'cross-tenant-test' + """, + (self.tenant_id,), + ).fetchone() + self.assertEqual(tenants, (principal_tenant, self.policy.tenant_reference)) + def test_http_accepts_maximum_length_command_identity(self) -> None: """A command key at the evidence limit remains executable after correlation tagging.""" context = AuthenticatedPrincipal( @@ -12072,6 +12112,7 @@ def test_http_accepts_maximum_length_command_identity(self) -> None: ), purpose_code="posting_control", credential_evidence_reference="urn:cwl:auth-evidence:posting-reader", + principal_kind="human", ) server = self._start_http_server(authorization_context=context) payload = self._billing_validated_payload( @@ -13335,6 +13376,7 @@ def _start_http_server( ), purpose_code="test_control", credential_evidence_reference="urn:cwl:auth-evidence:test-suite", + principal_kind="human", ) server = create_journal_proposal_server( DATABASE_URL, From 4dfdc09b39762b52b1e7401c7702be1d7f5c1b4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 04:10:07 +0900 Subject: [PATCH 07/55] docs: track authorization gap integration status --- docs/product-technical-gap-baseline.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 84bba09a..9c4707e7 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,6 +1,6 @@ # Product and technical gap baseline -**Evidence refresh:** 2026-08-27 (Asia/Seoul) +**Evidence refresh:** 2026-08-29 (Asia/Seoul) This file is the durable buyer-visible gap queue for `accounting-information-platform`. It records authority, dependency order, acceptance evidence, and product gaps that @@ -45,7 +45,7 @@ successes. | Documentation successor | Customer/operator README plus ADR enrichment rebuilt from the integrated foundation | Reconstructed from the exact integrated tree after the dependency roots land; stale ancestry must not merge | | Deterministic reconciliation and book-to-bank bridge | Second bounded reconciliation slice | Delivered on protected `develop`: deterministic proposal engine, exact bridge with finite-`Decimal` boundary, immutable-scope close-review projection, and the durable `reconciliation_run`/`reconciliation_exception`/`reconciliation_evidence` substrate with forced tenant RLS and immutable run scope | | Bank-reconciliation buyer slice | Close-out of the reconciliation vertical | Closed only after candidate/match allocation conservation, exception/approval workflow, and close-package provenance are integrated on top of the delivered substrate | -| Purpose-bound accounting authorization | Least-privilege operation authority | Versioned permission model with fail-closed decisions and immutable audit evidence | +| Purpose-bound accounting authorization | Least-privilege operation authority | Candidate implementation is under PR #34 with versioned permission model, explicit caller kind, fail-closed decisions, principal/requested-tenant provenance, and immutable audit evidence; remains open until integrated on protected `develop` | | Branch/release governance | Integration and release control plane | Protected `develop`/`main` effective policy including repository-owned exact-head accounting CI, independent reviews, and no normal force-push/deletion/bypass path | Issue numbering belongs to live tracker state; this table records durable roles so @@ -88,7 +88,9 @@ that renumbering cannot silently drop a commitment. provenance. 6. **Purpose-bound accounting authorization.** Keep tenant identity separate from operation authority for posting, reversal, close, tax, outbox, audit, and read - permissions. + permissions. PR #34 is the current implementation candidate; its exact-head + local and PostgreSQL evidence does not close this dependency until the protected + `develop` integration gates and approval pass together. Repository-governance work is an integration/release prerequisite running across all of the above: the protected branch policy must enforce the intended review and @@ -103,7 +105,7 @@ exact-head gates rather than leaving merge safety to convention alone. | P0 | Stateful commands require exact replay identity and immutable source evidence | Retries must not duplicate or mutate posting, reversal, close, tax, or statement-acceptance evidence | Tenant-scoped command keys, immutable source hashes/references, exact replay, changed-evidence conflict, and atomic command/outbox persistence proven in PostgreSQL | | P1 | Deterministic reconciliation and candidate/match allocation are partially delivered | Cash close can now explain differences and safely abstain, but approved candidates with exact split/aggregate conservation are not yet persistent across runs | Exact split/aggregate conservation with many-to-many allocation rows, temporal cutoff, concurrency safety, exception/approval workflow, provenance, and bridge equations from bank evidence to posted cash journals. Delivered so far: deterministic proposal engine, finite-Decimal bridge, immutable-scope close-review projection, and the durable run/exception/evidence substrate on protected `develop` | | P1 | Close-review projection integration is in flight | Controllers cannot yet read an exact, exportable close-review projection from one integrated head | The read-only projection and its authority/export contracts are integrated; it cannot approve or post. Outstanding: close-review opened only from integrated projection and its restacked successors | -| P1 | Purpose-bound authorization is absent | Tenant authentication alone is too coarse for accounting powers | Versioned operation-to-permission mapping, host identity adapter boundary, fail-closed authorization tests, immutable allow/deny audit evidence, and no caller/model-controlled promotion | +| P1 | Purpose-bound authorization is not yet integrated on protected `develop` | Tenant authentication alone is too coarse for accounting powers | PR #34 currently supplies versioned operation-to-permission mapping, explicit `human`/`service`/`agent` caller kind, host identity adapter boundary, fail-closed authorization tests, principal/requested-tenant audit provenance, immutable allow/deny evidence, and no caller/model-controlled promotion; protected integration and release gates remain outstanding | | P1 | Production operability and release proof remain incomplete | An operator cannot yet deploy, observe, back up, and recover the service with release-grade evidence | Supported deployment boundary, migration/rollback rehearsal, outbox-drain ownership, metrics/alerts, backup/restore exercise, integrated-head signed attestations, release version, artifact/source hashes, and recovery runbook evidence | | P2 | No frontend/design-system surface exists | Controllers have no visual close/reconciliation workflow | Introduce Figma source of truth, reusable design tokens, Storybook inventory with scene/edge-case event definitions, exact-value tables/exports, and browser accessibility tests only when a UI is actually added | From a778e3b6972e0b9e90933e1d0fd1a83f2c9b7f4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 12:34:41 +0900 Subject: [PATCH 08/55] fix: bind authorization evidence to tenant scope --- CHANGELOG.md | 2 ++ docs/SECURITY.md | 3 ++- docs/adr/0055-purpose-bound-authorization.md | 3 ++- docs/doctoring/STANDARD_TRACEABILITY.md | 2 +- src/accounting_information_platform/authorization.py | 2 ++ tests/test_authorization.py | 7 +++++++ 6 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9603fe06..0159ad96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ `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 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index dfff4764..68906863 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -33,7 +33,8 @@ versioned permission; soft-close and hard-close are independent permissions. Mis 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 standalone runner has no principal by +references, without raw tokens or full policy documents. The persistence boundary rejects a record +whose requested tenant differs from its storage tenant. The standalone runner has no principal by default and denies all accounting routes except health status. ## PostgreSQL runtime identities diff --git a/docs/adr/0055-purpose-bound-authorization.md b/docs/adr/0055-purpose-bound-authorization.md index 61fde299..8c9516f2 100644 --- a/docs/adr/0055-purpose-bound-authorization.md +++ b/docs/adr/0055-purpose-bound-authorization.md @@ -29,7 +29,8 @@ Every routed decision is appended to the tenant-scoped, forced-RLS `accounting_integration.authorization_decision_record` table. The record keeps the policy version, decision, principal/purpose evidence, principal tenant, requested tenant, operation, required permission, and bounded correlation identity. It never stores raw credentials. Database mutation -triggers make the evidence append-only. +triggers make the evidence append-only, and the persistence boundary rejects evidence whose +requested tenant differs from the tenant scope used to store it. The standalone runner has no authenticated principal by default and therefore exposes only health status until a trusted host adapter supplies a validated context to diff --git a/docs/doctoring/STANDARD_TRACEABILITY.md b/docs/doctoring/STANDARD_TRACEABILITY.md index c54a0b23..c262eaa9 100644 --- a/docs/doctoring/STANDARD_TRACEABILITY.md +++ b/docs/doctoring/STANDARD_TRACEABILITY.md @@ -17,7 +17,7 @@ | SLSA 1.2 / SPDX 2.3 / GitHub artifact attestations | Exact-head package evidence builds the wheel twice from a source-derived `SOURCE_DATE_EPOCH`, requires byte-identical SHA-256 digests, emits a deterministic SPDX 2.3 SBOM plus `source-provenance.json`, and makes `SHA256SUMS` cover the wheel, SBOM and source-provenance manifest. After checksum verification, the rebuilt wheel is installed with `--require-hashes` from a requirements line that carries the measured `--hash=sha256:` digest. The intermediate public-API smoke test imports the source tree over `PYTHONPATH` instead of an unhashed editable install. The manifest binds the verified source SHA to the wheel digest and SBOM digest before merge. Pull-request-controlled build/test code runs with `contents: read` only; OIDC, attestation and artifact-metadata write permissions are isolated in a distinct push-only `integrated-attestations` job. That job depends on the successful foundation build, downloads the immutable SHA-named evidence bundle, re-verifies checksums and `source_sha == github.sha`, and only then creates GitHub OIDC-backed signed provenance and SBOM attestations on integrated `develop`/`main` heads. A new runtime dependency fails closed until the SBOM generator represents its dependency relationship. This is evidence readiness, not a claimed SLSA level or certification | Accounting Foundation CI, `scripts/generate_supply_chain_evidence.py`, supply-chain evidence tests, GitHub workflow-permissions/OIDC/artifact-attestation guidance, and ADR 0048 | | OSV-Scanner / OSV.dev vulnerability data | Pull-request dependency evidence is tied to the immutable PR head and an independently fetched live base tip. The gate records dependency-manifest diffs and SHA-256 values, rejects stale/non-ancestor base identity, and scans the complete hash-locked exact-head Python dependency set with a digest-pinned OSV-Scanner image. A known vulnerability, scanner failure, skipped/unavailable evidence path or wrong checkout identity is non-passing; aggregate organization workflow success cannot substitute for an unexecuted dependency-review step | `exact-head-dependency-diff` CI job, `tests/test_dependency_review_contract.py`, OSV-Scanner source/lockfile guidance, and ADR 0048 | | AICPA Trust Services Criteria (SOC 2) | Auditors read an append-only history of posted, reversed, and closed facts from existing `outbox_event` rows, including already-published rows, without marking publish. Controllers also list stored `journal_reversal` lineage and durable hard-close receipts over HTTP without SQL. A HomeTax filing command fail-closes and persists a rejected receipt when the VAT register or the purpose-limited HomeTax credential is missing, and this slice never claims `transmitted` | HTTP audit-event history, HTTP journal-reversal list, HTTP period-close list, HTTP fail-closed HomeTax submission, ADR 0027, ADR 0029, ADR 0030, and ADR 0046 | -| CWL purpose-bound authorization contract | Tenant identity is not authority: a trusted host adapter supplies validated opaque principal, explicit `principal_kind` (`human`, `service`, or `agent`), purpose, permission, and authentication evidence; every route maps to a versioned operation decision; high-impact agent/model requests fail closed; and each accepted or denied decision retains both principal and requested tenant references in append-only tenant-scoped evidence without raw credentials | `AuthenticatedPrincipal`, route authorization regressions, migration 0015, and ADR 0055 | +| CWL purpose-bound authorization contract | Tenant identity is not authority: a trusted host adapter supplies validated opaque principal, explicit `principal_kind` (`human`, `service`, or `agent`), purpose, permission, and authentication evidence; every route maps to a versioned operation decision; high-impact agent/model requests fail closed; and each accepted or denied decision retains both principal and requested tenant references in append-only tenant-scoped evidence without raw credentials. The persistence boundary rejects requested-tenant evidence outside its storage scope. | `AuthenticatedPrincipal`, route authorization regressions, migration 0015, and ADR 0055 | | W3C PROV-O | Preserve entity, activity, agent, derivation, and attribution references across source proposal, posting, and append-only journal reversal lineage. The in-memory posting oracle scopes those identities by tenant and refuses to overwrite a posted `proposal_id`. Checked-in migrations cannot `UPDATE` or `DELETE` `general_journal` or `journal_entry_line` | Source-reference and receipt contracts, HTTP journal-reversal list, repository migration validation, ADR 0003, and ADR 0029 | | ISO/IEC/IEEE 42010:2022 | Keep stakeholder concerns, authority boundaries, architecture views, and decisions explicit | Architecture and ADR set | | JSON Schema Draft 2020-12 | Close external objects, version contracts, reject extra fields, and validate exact value formats. Policy-manifest `account_mappings` are unique by `account_role_code` before policy load because `uniqueItems` compares whole objects | Three contract schemas, `load_accounting_policy`, ADR 0005, and ADR 0007 | diff --git a/src/accounting_information_platform/authorization.py b/src/accounting_information_platform/authorization.py index a898418d..565742ea 100644 --- a/src/accounting_information_platform/authorization.py +++ b/src/accounting_information_platform/authorization.py @@ -190,6 +190,8 @@ def record_authorization_decision( """Append one decision to the tenant-scoped PostgreSQL authorization evidence table.""" if not correlation_reference or len(correlation_reference) > 512: raise ValueError("authorization correlation reference must contain 1 to 512 characters") + if decision.requested_tenant_reference != tenant_reference: + raise ValueError("authorization decision tenant scope must match requested tenant reference") from .persistence import PostgresPostingLedger ledger = PostgresPostingLedger(database_url, tenant_reference) diff --git a/tests/test_authorization.py b/tests/test_authorization.py index d1d04d1f..87f4fbd4 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -238,6 +238,13 @@ def test_authorization_correlation_is_required_before_database_work(self) -> Non with self.assertRaisesRegex(ValueError, "correlation reference"): record_authorization_decision("postgresql://unused", TENANT, decision, "") + def test_authorization_evidence_rejects_requested_tenant_outside_storage_scope(self) -> None: + """Audit evidence cannot claim a requested tenant different from its storage scope.""" + decision = authorize(principal("accounting.read_catalog"), "urn:cwl:tenant_other", "read_catalog") + + with self.assertRaisesRegex(ValueError, "tenant scope"): + record_authorization_decision("postgresql://unused", TENANT, decision, "/account-role-mappings") + def test_internal_handlers_keep_their_missing_header_guard(self) -> None: """Direct handler dispatch remains fail-closed even outside the normal router.""" handler_names = ( From c656ab9e96baccdb2ab1851eb9cccbe018b8d380 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 20:29:30 +0900 Subject: [PATCH 09/55] fix(auth): reject malformed close bodies before authorization --- CHANGELOG.md | 2 ++ docs/adr/0055-purpose-bound-authorization.md | 5 +++++ src/accounting_information_platform/http_api.py | 6 ++++-- tests/test_authorization.py | 6 +++++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0159ad96..518c1347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- 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. + - 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 diff --git a/docs/adr/0055-purpose-bound-authorization.md b/docs/adr/0055-purpose-bound-authorization.md index 8c9516f2..cd461c23 100644 --- a/docs/adr/0055-purpose-bound-authorization.md +++ b/docs/adr/0055-purpose-bound-authorization.md @@ -13,6 +13,11 @@ but they do not replace an application decision made before a route invokes doma ## Decision +The HTTP boundary classifies a period-close authorization operation only after +the request body is valid JSON object data. Malformed or non-object bodies are +rejected without recording an allowed hard-close decision, because they are not +accounting commands. + The trusted host identity adapter supplies an immutable `AuthenticatedPrincipal` containing only validated opaque principal, tenant, authentication-context, purpose, permission, and credential- evidence references plus an explicit `principal_kind`. `principal_kind` must be one of `human`, diff --git a/src/accounting_information_platform/http_api.py b/src/accounting_information_platform/http_api.py index 724566a2..f72f8b1e 100644 --- a/src/accounting_information_platform/http_api.py +++ b/src/accounting_information_platform/http_api.py @@ -1928,8 +1928,10 @@ def _post_authorization_operation(path: str, raw_body: bytes) -> str | None: try: payload = json.loads(raw_body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): - return "hard_close_period" - return period_close_operation(payload.get("period_status_code") if isinstance(payload, dict) else None) + return None + if not isinstance(payload, dict): + return None + return period_close_operation(payload.get("period_status_code")) def _authorization_correlation(path: str, raw_body: bytes) -> str: diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 87f4fbd4..6f385b16 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -201,7 +201,11 @@ def test_http_operation_and_correlation_helpers_are_bounded(self) -> None: ) self.assertEqual( _post_authorization_operation("/period-closes", b"not-json"), - "hard_close_period", + None, + ) + self.assertEqual( + _post_authorization_operation("/period-closes", b"[]"), + None, ) self.assertEqual(_post_authorization_operation("/unknown", b"{}"), None) self.assertEqual( From 4868dbae5a1772fab84074416df3cf38b5f4da18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 22:57:50 +0900 Subject: [PATCH 10/55] fix(auth): reject caller-constructed decisions --- CHANGELOG.md | 3 +++ docs/SECURITY.md | 5 +++-- docs/adr/0055-purpose-bound-authorization.md | 4 +++- docs/doctoring/STANDARD_TRACEABILITY.md | 2 +- .../authorization.py | 17 +++++++++++--- tests/test_authorization.py | 22 +++++++++++++++++++ 6 files changed, 46 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 518c1347..299c5b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +- Authorization evidence persistence now accepts only immutable decisions issued by the + authorization evaluator; a caller-constructed `allowed` decision cannot be promoted into + durable audit evidence. - 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. - Authorization principals now require an explicit `principal_kind` (`human`, `service`, or diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 68906863..c8202d3d 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -34,8 +34,9 @@ tenant-mismatched, insufficient, or agent-originated high-impact decisions fail `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. The standalone runner has no principal by -default and denies all accounting routes except health status. +whose requested tenant differs from its storage tenant and accepts only decisions issued by the +authorization evaluator, preventing caller-constructed allow evidence. The standalone runner has +no principal by default and denies all accounting routes except health status. ## PostgreSQL runtime identities diff --git a/docs/adr/0055-purpose-bound-authorization.md b/docs/adr/0055-purpose-bound-authorization.md index cd461c23..940bac32 100644 --- a/docs/adr/0055-purpose-bound-authorization.md +++ b/docs/adr/0055-purpose-bound-authorization.md @@ -35,7 +35,9 @@ Every routed decision is appended to the tenant-scoped, forced-RLS decision, principal/purpose evidence, principal tenant, requested tenant, operation, required permission, and bounded correlation identity. It never stores raw credentials. Database mutation triggers make the evidence append-only, and the persistence boundary rejects evidence whose -requested tenant differs from the tenant scope used to store it. +requested tenant differs from the tenant scope used to store it. The persistence boundary also +accepts only decisions issued by the `authorize` evaluator, so a caller cannot construct an +`allowed` decision and promote it into durable evidence. The standalone runner has no authenticated principal by default and therefore exposes only health status until a trusted host adapter supplies a validated context to diff --git a/docs/doctoring/STANDARD_TRACEABILITY.md b/docs/doctoring/STANDARD_TRACEABILITY.md index c262eaa9..c8257977 100644 --- a/docs/doctoring/STANDARD_TRACEABILITY.md +++ b/docs/doctoring/STANDARD_TRACEABILITY.md @@ -17,7 +17,7 @@ | SLSA 1.2 / SPDX 2.3 / GitHub artifact attestations | Exact-head package evidence builds the wheel twice from a source-derived `SOURCE_DATE_EPOCH`, requires byte-identical SHA-256 digests, emits a deterministic SPDX 2.3 SBOM plus `source-provenance.json`, and makes `SHA256SUMS` cover the wheel, SBOM and source-provenance manifest. After checksum verification, the rebuilt wheel is installed with `--require-hashes` from a requirements line that carries the measured `--hash=sha256:` digest. The intermediate public-API smoke test imports the source tree over `PYTHONPATH` instead of an unhashed editable install. The manifest binds the verified source SHA to the wheel digest and SBOM digest before merge. Pull-request-controlled build/test code runs with `contents: read` only; OIDC, attestation and artifact-metadata write permissions are isolated in a distinct push-only `integrated-attestations` job. That job depends on the successful foundation build, downloads the immutable SHA-named evidence bundle, re-verifies checksums and `source_sha == github.sha`, and only then creates GitHub OIDC-backed signed provenance and SBOM attestations on integrated `develop`/`main` heads. A new runtime dependency fails closed until the SBOM generator represents its dependency relationship. This is evidence readiness, not a claimed SLSA level or certification | Accounting Foundation CI, `scripts/generate_supply_chain_evidence.py`, supply-chain evidence tests, GitHub workflow-permissions/OIDC/artifact-attestation guidance, and ADR 0048 | | OSV-Scanner / OSV.dev vulnerability data | Pull-request dependency evidence is tied to the immutable PR head and an independently fetched live base tip. The gate records dependency-manifest diffs and SHA-256 values, rejects stale/non-ancestor base identity, and scans the complete hash-locked exact-head Python dependency set with a digest-pinned OSV-Scanner image. A known vulnerability, scanner failure, skipped/unavailable evidence path or wrong checkout identity is non-passing; aggregate organization workflow success cannot substitute for an unexecuted dependency-review step | `exact-head-dependency-diff` CI job, `tests/test_dependency_review_contract.py`, OSV-Scanner source/lockfile guidance, and ADR 0048 | | AICPA Trust Services Criteria (SOC 2) | Auditors read an append-only history of posted, reversed, and closed facts from existing `outbox_event` rows, including already-published rows, without marking publish. Controllers also list stored `journal_reversal` lineage and durable hard-close receipts over HTTP without SQL. A HomeTax filing command fail-closes and persists a rejected receipt when the VAT register or the purpose-limited HomeTax credential is missing, and this slice never claims `transmitted` | HTTP audit-event history, HTTP journal-reversal list, HTTP period-close list, HTTP fail-closed HomeTax submission, ADR 0027, ADR 0029, ADR 0030, and ADR 0046 | -| CWL purpose-bound authorization contract | Tenant identity is not authority: a trusted host adapter supplies validated opaque principal, explicit `principal_kind` (`human`, `service`, or `agent`), purpose, permission, and authentication evidence; every route maps to a versioned operation decision; high-impact agent/model requests fail closed; and each accepted or denied decision retains both principal and requested tenant references in append-only tenant-scoped evidence without raw credentials. The persistence boundary rejects requested-tenant evidence outside its storage scope. | `AuthenticatedPrincipal`, route authorization regressions, migration 0015, and ADR 0055 | +| CWL purpose-bound authorization contract | Tenant identity is not authority: a trusted host adapter supplies validated opaque principal, explicit `principal_kind` (`human`, `service`, or `agent`), purpose, permission, and authentication evidence; every route maps to a versioned operation decision; high-impact agent/model requests fail closed; and each accepted or denied decision retains both principal and requested tenant references in append-only tenant-scoped evidence without raw credentials. The persistence boundary rejects requested-tenant evidence outside its storage scope and rejects caller-constructed decisions that were not issued by the authorization evaluator. | `AuthenticatedPrincipal`, `AuthorizationDecision`, `record_authorization_decision`, route authorization regressions, migration 0015, and ADR 0055 | | W3C PROV-O | Preserve entity, activity, agent, derivation, and attribution references across source proposal, posting, and append-only journal reversal lineage. The in-memory posting oracle scopes those identities by tenant and refuses to overwrite a posted `proposal_id`. Checked-in migrations cannot `UPDATE` or `DELETE` `general_journal` or `journal_entry_line` | Source-reference and receipt contracts, HTTP journal-reversal list, repository migration validation, ADR 0003, and ADR 0029 | | ISO/IEC/IEEE 42010:2022 | Keep stakeholder concerns, authority boundaries, architecture views, and decisions explicit | Architecture and ADR set | | JSON Schema Draft 2020-12 | Close external objects, version contracts, reject extra fields, and validate exact value formats. Policy-manifest `account_mappings` are unique by `account_role_code` before policy load because `uniqueItems` compares whole objects | Three contract schemas, `load_accounting_policy`, ADR 0005, and ADR 0007 | diff --git a/src/accounting_information_platform/authorization.py b/src/accounting_information_platform/authorization.py index 565742ea..313b25b3 100644 --- a/src/accounting_information_platform/authorization.py +++ b/src/accounting_information_platform/authorization.py @@ -8,7 +8,7 @@ from __future__ import annotations import re -from dataclasses import dataclass +from dataclasses import dataclass, field from types import MappingProxyType from typing import Mapping @@ -18,6 +18,7 @@ AUTHORIZATION_POLICY_VERSION = "accounting-authorization-v1" _PERMISSION_PATTERN = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$") _PRINCIPAL_KINDS = frozenset(("human", "service", "agent")) +_AUTHORIZATION_ISSUANCE_TOKEN = object() _OPERATION_PERMISSIONS: Mapping[str, str] = MappingProxyType( { @@ -103,6 +104,14 @@ class AuthorizationDecision: policy_version: str decision_code: str allowed: bool + _issuance_token: object = field(default=None, init=False, repr=False, compare=False) + + +def _issue_authorization_decision(**values: object) -> AuthorizationDecision: + """Create decision evidence only through the policy evaluator.""" + decision = AuthorizationDecision(**values) + object.__setattr__(decision, "_issuance_token", _AUTHORIZATION_ISSUANCE_TOKEN) + return decision def permission_for_operation(operation_code: str) -> str | None: @@ -119,7 +128,7 @@ def authorize( _require_reference(requested_tenant_reference, "requested tenant reference") permission_code = permission_for_operation(operation_code) or "" if principal is None: - return AuthorizationDecision( + return _issue_authorization_decision( principal_reference="urn:cwl:principal:unauthenticated", tenant_reference=requested_tenant_reference, requested_tenant_reference=requested_tenant_reference, @@ -140,7 +149,7 @@ def authorize( and not agent_restricted and permission_code in principal.granted_permission_codes ) - return AuthorizationDecision( + return _issue_authorization_decision( principal_reference=principal.principal_reference, tenant_reference=principal.tenant_reference, requested_tenant_reference=requested_tenant_reference, @@ -190,6 +199,8 @@ def record_authorization_decision( """Append one decision to the tenant-scoped PostgreSQL authorization evidence table.""" if not correlation_reference or len(correlation_reference) > 512: raise ValueError("authorization correlation reference must contain 1 to 512 characters") + if decision._issuance_token is not _AUTHORIZATION_ISSUANCE_TOKEN: + raise ValueError("authorization decision must be issued by authorize") if decision.requested_tenant_reference != tenant_reference: raise ValueError("authorization decision tenant scope must match requested tenant reference") from .persistence import PostgresPostingLedger diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 6f385b16..5013342b 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -12,6 +12,7 @@ from accounting_information_platform.authorization import ( AUTHORIZATION_POLICY_VERSION, AuthenticatedPrincipal, + AuthorizationDecision, authorize, period_close_operation, record_authorization_decision, @@ -249,6 +250,27 @@ def test_authorization_evidence_rejects_requested_tenant_outside_storage_scope(s with self.assertRaisesRegex(ValueError, "tenant scope"): record_authorization_decision("postgresql://unused", TENANT, decision, "/account-role-mappings") + def test_authorization_evidence_rejects_caller_constructed_allow(self) -> None: + """Audit persistence cannot promote a caller-constructed decision to allowed evidence.""" + decision = AuthorizationDecision( + principal_reference="urn:cwl:principal:forged", + tenant_reference=TENANT, + requested_tenant_reference=TENANT, + authentication_context_reference="urn:cwl:authentication:forged", + credential_evidence_reference="urn:cwl:auth-evidence:forged", + operation_code="read_catalog", + permission_code="accounting.read_catalog", + purpose_code="catalog_read", + policy_version=AUTHORIZATION_POLICY_VERSION, + decision_code="allowed", + allowed=True, + ) + + with self.assertRaisesRegex(ValueError, "issued by authorize"): + record_authorization_decision( + "postgresql://unused", TENANT, decision, "forged-decision" + ) + def test_internal_handlers_keep_their_missing_header_guard(self) -> None: """Direct handler dispatch remains fail-closed even outside the normal router.""" handler_names = ( From 010c896363c528e790052b2463b4c937d29e0019 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 23:08:16 +0900 Subject: [PATCH 11/55] fix(auth): preserve decision provenance --- CHANGELOG.md | 7 ++-- docs/SECURITY.md | 6 +-- docs/adr/0055-purpose-bound-authorization.md | 5 ++- docs/doctoring/STANDARD_TRACEABILITY.md | 2 +- .../authorization.py | 40 +++++++++++++++++-- tests/test_authorization.py | 30 ++++++++++++++ 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 299c5b01..23143329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ ## [Unreleased] -- Authorization evidence persistence now accepts only immutable decisions issued by the - authorization evaluator; a caller-constructed `allowed` decision cannot be promoted into - durable audit evidence. +- 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. - Authorization principals now require an explicit `principal_kind` (`human`, `service`, or diff --git a/docs/SECURITY.md b/docs/SECURITY.md index c8202d3d..5a4bed32 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -34,9 +34,9 @@ tenant-mismatched, insufficient, or agent-originated high-impact decisions fail `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 decisions issued by the -authorization evaluator, preventing caller-constructed allow evidence. The standalone runner has -no principal by default and denies all accounting routes except health status. +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 diff --git a/docs/adr/0055-purpose-bound-authorization.md b/docs/adr/0055-purpose-bound-authorization.md index 940bac32..f0521e6f 100644 --- a/docs/adr/0055-purpose-bound-authorization.md +++ b/docs/adr/0055-purpose-bound-authorization.md @@ -36,8 +36,9 @@ decision, principal/purpose evidence, principal tenant, requested tenant, operat permission, and bounded correlation identity. It never stores raw credentials. Database mutation triggers make the evidence append-only, and the persistence boundary rejects evidence whose requested tenant differs from the tenant scope used to store it. The persistence boundary also -accepts only decisions issued by the `authorize` evaluator, so a caller cannot construct an -`allowed` decision and promote it into durable evidence. +accepts only unchanged decisions issued by the `authorize` evaluator, so a caller cannot +construct or mutate an `allowed` decision and promote it into durable evidence; copying an +evaluator decision retains its provenance. The standalone runner has no authenticated principal by default and therefore exposes only health status until a trusted host adapter supplies a validated context to diff --git a/docs/doctoring/STANDARD_TRACEABILITY.md b/docs/doctoring/STANDARD_TRACEABILITY.md index c8257977..ff9a1b87 100644 --- a/docs/doctoring/STANDARD_TRACEABILITY.md +++ b/docs/doctoring/STANDARD_TRACEABILITY.md @@ -17,7 +17,7 @@ | SLSA 1.2 / SPDX 2.3 / GitHub artifact attestations | Exact-head package evidence builds the wheel twice from a source-derived `SOURCE_DATE_EPOCH`, requires byte-identical SHA-256 digests, emits a deterministic SPDX 2.3 SBOM plus `source-provenance.json`, and makes `SHA256SUMS` cover the wheel, SBOM and source-provenance manifest. After checksum verification, the rebuilt wheel is installed with `--require-hashes` from a requirements line that carries the measured `--hash=sha256:` digest. The intermediate public-API smoke test imports the source tree over `PYTHONPATH` instead of an unhashed editable install. The manifest binds the verified source SHA to the wheel digest and SBOM digest before merge. Pull-request-controlled build/test code runs with `contents: read` only; OIDC, attestation and artifact-metadata write permissions are isolated in a distinct push-only `integrated-attestations` job. That job depends on the successful foundation build, downloads the immutable SHA-named evidence bundle, re-verifies checksums and `source_sha == github.sha`, and only then creates GitHub OIDC-backed signed provenance and SBOM attestations on integrated `develop`/`main` heads. A new runtime dependency fails closed until the SBOM generator represents its dependency relationship. This is evidence readiness, not a claimed SLSA level or certification | Accounting Foundation CI, `scripts/generate_supply_chain_evidence.py`, supply-chain evidence tests, GitHub workflow-permissions/OIDC/artifact-attestation guidance, and ADR 0048 | | OSV-Scanner / OSV.dev vulnerability data | Pull-request dependency evidence is tied to the immutable PR head and an independently fetched live base tip. The gate records dependency-manifest diffs and SHA-256 values, rejects stale/non-ancestor base identity, and scans the complete hash-locked exact-head Python dependency set with a digest-pinned OSV-Scanner image. A known vulnerability, scanner failure, skipped/unavailable evidence path or wrong checkout identity is non-passing; aggregate organization workflow success cannot substitute for an unexecuted dependency-review step | `exact-head-dependency-diff` CI job, `tests/test_dependency_review_contract.py`, OSV-Scanner source/lockfile guidance, and ADR 0048 | | AICPA Trust Services Criteria (SOC 2) | Auditors read an append-only history of posted, reversed, and closed facts from existing `outbox_event` rows, including already-published rows, without marking publish. Controllers also list stored `journal_reversal` lineage and durable hard-close receipts over HTTP without SQL. A HomeTax filing command fail-closes and persists a rejected receipt when the VAT register or the purpose-limited HomeTax credential is missing, and this slice never claims `transmitted` | HTTP audit-event history, HTTP journal-reversal list, HTTP period-close list, HTTP fail-closed HomeTax submission, ADR 0027, ADR 0029, ADR 0030, and ADR 0046 | -| CWL purpose-bound authorization contract | Tenant identity is not authority: a trusted host adapter supplies validated opaque principal, explicit `principal_kind` (`human`, `service`, or `agent`), purpose, permission, and authentication evidence; every route maps to a versioned operation decision; high-impact agent/model requests fail closed; and each accepted or denied decision retains both principal and requested tenant references in append-only tenant-scoped evidence without raw credentials. The persistence boundary rejects requested-tenant evidence outside its storage scope and rejects caller-constructed decisions that were not issued by the authorization evaluator. | `AuthenticatedPrincipal`, `AuthorizationDecision`, `record_authorization_decision`, route authorization regressions, migration 0015, and ADR 0055 | +| CWL purpose-bound authorization contract | Tenant identity is not authority: a trusted host adapter supplies validated opaque principal, explicit `principal_kind` (`human`, `service`, or `agent`), purpose, permission, and authentication evidence; every route maps to a versioned operation decision; high-impact agent/model requests fail closed; and each accepted or denied decision retains both principal and requested tenant references in append-only tenant-scoped evidence without raw credentials. The persistence boundary rejects requested-tenant evidence outside its storage scope and rejects caller-constructed or mutated decisions that were not issued unchanged by the authorization evaluator. | `AuthenticatedPrincipal`, `AuthorizationDecision`, `record_authorization_decision`, route authorization regressions, and copied/mutated-decision regressions, migration 0015, and ADR 0055 | | W3C PROV-O | Preserve entity, activity, agent, derivation, and attribution references across source proposal, posting, and append-only journal reversal lineage. The in-memory posting oracle scopes those identities by tenant and refuses to overwrite a posted `proposal_id`. Checked-in migrations cannot `UPDATE` or `DELETE` `general_journal` or `journal_entry_line` | Source-reference and receipt contracts, HTTP journal-reversal list, repository migration validation, ADR 0003, and ADR 0029 | | ISO/IEC/IEEE 42010:2022 | Keep stakeholder concerns, authority boundaries, architecture views, and decisions explicit | Architecture and ADR set | | JSON Schema Draft 2020-12 | Close external objects, version contracts, reject extra fields, and validate exact value formats. Policy-manifest `account_mappings` are unique by `account_role_code` before policy load because `uniqueItems` compares whole objects | Three contract schemas, `load_accounting_policy`, ADR 0005, and ADR 0007 | diff --git a/src/accounting_information_platform/authorization.py b/src/accounting_information_platform/authorization.py index 313b25b3..bdbf0f2f 100644 --- a/src/accounting_information_platform/authorization.py +++ b/src/accounting_information_platform/authorization.py @@ -7,8 +7,10 @@ from __future__ import annotations +import hashlib import re from dataclasses import dataclass, field +from enum import Enum from types import MappingProxyType from typing import Mapping @@ -18,7 +20,28 @@ AUTHORIZATION_POLICY_VERSION = "accounting-authorization-v1" _PERMISSION_PATTERN = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$") _PRINCIPAL_KINDS = frozenset(("human", "service", "agent")) -_AUTHORIZATION_ISSUANCE_TOKEN = object() + + +class _AuthorizationIssuance(Enum): + """Stable in-process provenance marker that survives supported copying.""" + + ISSUED = "issued" + + +_AUTHORIZATION_ISSUANCE_TOKEN = _AuthorizationIssuance.ISSUED +_DECISION_VALUE_NAMES = ( + "principal_reference", + "tenant_reference", + "requested_tenant_reference", + "authentication_context_reference", + "credential_evidence_reference", + "operation_code", + "permission_code", + "purpose_code", + "policy_version", + "decision_code", + "allowed", +) _OPERATION_PERMISSIONS: Mapping[str, str] = MappingProxyType( { @@ -105,12 +128,20 @@ class AuthorizationDecision: decision_code: str allowed: bool _issuance_token: object = field(default=None, init=False, repr=False, compare=False) + _decision_fingerprint: str = field(default="", init=False, repr=False, compare=False) + + +def _decision_fingerprint(decision: AuthorizationDecision) -> str: + """Fingerprint decision values so post-issuance mutation fails closed.""" + values = tuple(getattr(decision, name) for name in _DECISION_VALUE_NAMES) + return hashlib.sha256(repr(values).encode("utf-8")).hexdigest() def _issue_authorization_decision(**values: object) -> AuthorizationDecision: """Create decision evidence only through the policy evaluator.""" decision = AuthorizationDecision(**values) object.__setattr__(decision, "_issuance_token", _AUTHORIZATION_ISSUANCE_TOKEN) + object.__setattr__(decision, "_decision_fingerprint", _decision_fingerprint(decision)) return decision @@ -199,8 +230,11 @@ def record_authorization_decision( """Append one decision to the tenant-scoped PostgreSQL authorization evidence table.""" if not correlation_reference or len(correlation_reference) > 512: raise ValueError("authorization correlation reference must contain 1 to 512 characters") - if decision._issuance_token is not _AUTHORIZATION_ISSUANCE_TOKEN: - raise ValueError("authorization decision must be issued by authorize") + if ( + decision._issuance_token is not _AUTHORIZATION_ISSUANCE_TOKEN + or decision._decision_fingerprint != _decision_fingerprint(decision) + ): + raise ValueError("authorization decision must be issued by authorize and remain unchanged") if decision.requested_tenant_reference != tenant_reference: raise ValueError("authorization decision tenant scope must match requested tenant reference") from .persistence import PostgresPostingLedger diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 5013342b..617458b1 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import json import unittest from email.message import Message @@ -271,6 +272,35 @@ def test_authorization_evidence_rejects_caller_constructed_allow(self) -> None: "postgresql://unused", TENANT, decision, "forged-decision" ) + def test_authorization_evidence_accepts_a_copied_policy_decision(self) -> None: + """Copying evaluator evidence preserves its provenance for the persistence boundary.""" + decision = copy.deepcopy( + authorize(principal("accounting.read_catalog"), TENANT, "read_catalog") + ) + ledger = mock.Mock() + session = mock.MagicMock() + session.__enter__.return_value = mock.Mock() + ledger._session.return_value = session + ledger._require_tenant.return_value = "tenant-id" + with mock.patch( + "accounting_information_platform.persistence.PostgresPostingLedger", + return_value=ledger, + ): + record_authorization_decision( + "postgresql://unused", TENANT, decision, "copied-decision" + ) + ledger._require_tenant.assert_called_once() + + def test_authorization_evidence_rejects_mutated_policy_decision(self) -> None: + """Mutating an issued decision cannot change the evidence accepted for persistence.""" + decision = authorize(principal("accounting.read_catalog"), TENANT, "read_catalog") + object.__setattr__(decision, "allowed", False) + + with self.assertRaisesRegex(ValueError, "issued by authorize"): + record_authorization_decision( + "postgresql://unused", TENANT, decision, "mutated-decision" + ) + def test_internal_handlers_keep_their_missing_header_guard(self) -> None: """Direct handler dispatch remains fail-closed even outside the normal router.""" handler_names = ( From 7b7bddadffd10874beb441c5b03fc503a5e9df0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 23:15:33 +0900 Subject: [PATCH 12/55] fix(auth): seal authorization decisions --- .../authorization.py | 13 +++++++++--- tests/test_authorization.py | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/accounting_information_platform/authorization.py b/src/accounting_information_platform/authorization.py index bdbf0f2f..c54a6be4 100644 --- a/src/accounting_information_platform/authorization.py +++ b/src/accounting_information_platform/authorization.py @@ -8,9 +8,11 @@ from __future__ import annotations import hashlib +import hmac import re from dataclasses import dataclass, field from enum import Enum +from secrets import token_bytes from types import MappingProxyType from typing import Mapping @@ -29,6 +31,7 @@ class _AuthorizationIssuance(Enum): _AUTHORIZATION_ISSUANCE_TOKEN = _AuthorizationIssuance.ISSUED +_AUTHORIZATION_DECISION_SEAL_KEY = token_bytes(32) _DECISION_VALUE_NAMES = ( "principal_reference", "tenant_reference", @@ -127,14 +130,18 @@ class AuthorizationDecision: policy_version: str decision_code: str allowed: bool - _issuance_token: object = field(default=None, init=False, repr=False, compare=False) - _decision_fingerprint: str = field(default="", init=False, repr=False, compare=False) + _issuance_token: object = field(default=None, repr=False, compare=False) + _decision_fingerprint: str = field(default="", repr=False, compare=False) def _decision_fingerprint(decision: AuthorizationDecision) -> str: """Fingerprint decision values so post-issuance mutation fails closed.""" values = tuple(getattr(decision, name) for name in _DECISION_VALUE_NAMES) - return hashlib.sha256(repr(values).encode("utf-8")).hexdigest() + return hmac.new( + _AUTHORIZATION_DECISION_SEAL_KEY, + repr(values).encode("utf-8"), + hashlib.sha256, + ).hexdigest() def _issue_authorization_decision(**values: object) -> AuthorizationDecision: diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 617458b1..4682a274 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -5,6 +5,7 @@ import copy import json import unittest +from dataclasses import replace from email.message import Message from types import SimpleNamespace import unittest.mock as mock @@ -291,6 +292,25 @@ def test_authorization_evidence_accepts_a_copied_policy_decision(self) -> None: ) ledger._require_tenant.assert_called_once() + def test_authorization_evidence_accepts_an_unchanged_replacement(self) -> None: + """Replacing an evaluator decision without changing values preserves its provenance.""" + decision = replace( + authorize(principal("accounting.read_catalog"), TENANT, "read_catalog") + ) + ledger = mock.Mock() + session = mock.MagicMock() + session.__enter__.return_value = mock.Mock() + ledger._session.return_value = session + ledger._require_tenant.return_value = "tenant-id" + with mock.patch( + "accounting_information_platform.persistence.PostgresPostingLedger", + return_value=ledger, + ): + record_authorization_decision( + "postgresql://unused", TENANT, decision, "replacement-decision" + ) + ledger._require_tenant.assert_called_once() + def test_authorization_evidence_rejects_mutated_policy_decision(self) -> None: """Mutating an issued decision cannot change the evidence accepted for persistence.""" decision = authorize(principal("accounting.read_catalog"), TENANT, "read_catalog") From a8d6ffdf971f6c9ff4c561137196772243b3168c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 23:19:15 +0900 Subject: [PATCH 13/55] fix(auth): remove forgeable issuance marker --- .../authorization.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/accounting_information_platform/authorization.py b/src/accounting_information_platform/authorization.py index c54a6be4..100fc8ed 100644 --- a/src/accounting_information_platform/authorization.py +++ b/src/accounting_information_platform/authorization.py @@ -11,7 +11,6 @@ import hmac import re from dataclasses import dataclass, field -from enum import Enum from secrets import token_bytes from types import MappingProxyType from typing import Mapping @@ -24,13 +23,6 @@ _PRINCIPAL_KINDS = frozenset(("human", "service", "agent")) -class _AuthorizationIssuance(Enum): - """Stable in-process provenance marker that survives supported copying.""" - - ISSUED = "issued" - - -_AUTHORIZATION_ISSUANCE_TOKEN = _AuthorizationIssuance.ISSUED _AUTHORIZATION_DECISION_SEAL_KEY = token_bytes(32) _DECISION_VALUE_NAMES = ( "principal_reference", @@ -130,7 +122,6 @@ class AuthorizationDecision: policy_version: str decision_code: str allowed: bool - _issuance_token: object = field(default=None, repr=False, compare=False) _decision_fingerprint: str = field(default="", repr=False, compare=False) @@ -147,7 +138,6 @@ def _decision_fingerprint(decision: AuthorizationDecision) -> str: def _issue_authorization_decision(**values: object) -> AuthorizationDecision: """Create decision evidence only through the policy evaluator.""" decision = AuthorizationDecision(**values) - object.__setattr__(decision, "_issuance_token", _AUTHORIZATION_ISSUANCE_TOKEN) object.__setattr__(decision, "_decision_fingerprint", _decision_fingerprint(decision)) return decision @@ -237,10 +227,7 @@ def record_authorization_decision( """Append one decision to the tenant-scoped PostgreSQL authorization evidence table.""" if not correlation_reference or len(correlation_reference) > 512: raise ValueError("authorization correlation reference must contain 1 to 512 characters") - if ( - decision._issuance_token is not _AUTHORIZATION_ISSUANCE_TOKEN - or decision._decision_fingerprint != _decision_fingerprint(decision) - ): + if decision._decision_fingerprint != _decision_fingerprint(decision): raise ValueError("authorization decision must be issued by authorize and remain unchanged") if decision.requested_tenant_reference != tenant_reference: raise ValueError("authorization decision tenant scope must match requested tenant reference") From 5b7bfc2930960a33664396810f0b6e2ead07f96d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 30 Aug 2026 03:34:47 +0900 Subject: [PATCH 14/55] fix(auth): authorize malformed period closes --- .../http_api.py | 4 +- tests/test_authorization.py | 4 +- tests/test_postgres_posting.py | 60 +++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/accounting_information_platform/http_api.py b/src/accounting_information_platform/http_api.py index f72f8b1e..e823a36e 100644 --- a/src/accounting_information_platform/http_api.py +++ b/src/accounting_information_platform/http_api.py @@ -1928,9 +1928,9 @@ def _post_authorization_operation(path: str, raw_body: bytes) -> str | None: try: payload = json.loads(raw_body.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): - return None + return period_close_operation(None) if not isinstance(payload, dict): - return None + return period_close_operation(None) return period_close_operation(payload.get("period_status_code")) diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 4682a274..73f6f847 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -204,11 +204,11 @@ def test_http_operation_and_correlation_helpers_are_bounded(self) -> None: ) self.assertEqual( _post_authorization_operation("/period-closes", b"not-json"), - None, + "hard_close_period", ) self.assertEqual( _post_authorization_operation("/period-closes", b"[]"), - None, + "hard_close_period", ) self.assertEqual(_post_authorization_operation("/unknown", b"{}"), None) self.assertEqual( diff --git a/tests/test_postgres_posting.py b/tests/test_postgres_posting.py index ef16f9a4..2c49655a 100644 --- a/tests/test_postgres_posting.py +++ b/tests/test_postgres_posting.py @@ -12066,6 +12066,66 @@ def test_http_requires_route_permission_and_records_authorization_evidence(self) connection.execute(mutation, (self.tenant_id,)) server.shutdown() + def test_malformed_period_close_is_denied_and_recorded_before_validation(self) -> None: + """Unclassifiable close input cannot bypass high-impact authorization or write rows.""" + context = AuthenticatedPrincipal( + principal_reference="urn:cwl:principal:catalog-reader", + tenant_reference=self.policy.tenant_reference, + authentication_context_reference="urn:cwl:authentication:catalog-reader", + granted_permission_codes=frozenset({"accounting.read_close"}), + purpose_code="close_read", + credential_evidence_reference="urn:cwl:auth-evidence:catalog-reader", + principal_kind="human", + ) + server = self._start_http_server(authorization_context=context) + + malformed_status, _malformed = self._http_raw( + "POST", "/period-closes", b"not-json", self.policy.tenant_reference + ) + non_object_status, _non_object = self._http_raw( + "POST", "/period-closes", b"[]", self.policy.tenant_reference + ) + + self.assertEqual(malformed_status, 403) + self.assertEqual(non_object_status, 403) + self.assertEqual(self._count_table("accounting_core.general_journal"), 0) + self.assertEqual(self._count_table("accounting_integration.outbox_event"), 0) + self.assertEqual( + self._count_table("accounting_integration.authorization_decision_record"), + 2, + ) + server.shutdown() + + def test_authorized_malformed_period_close_preserves_validation_error(self) -> None: + """A hard-close-capable caller still receives client validation after audit authorization.""" + context = AuthenticatedPrincipal( + principal_reference="urn:cwl:principal:close-writer", + tenant_reference=self.policy.tenant_reference, + authentication_context_reference="urn:cwl:authentication:close-writer", + granted_permission_codes=frozenset({"accounting.hard_close_period"}), + purpose_code="period_close", + credential_evidence_reference="urn:cwl:auth-evidence:close-writer", + principal_kind="human", + ) + server = self._start_http_server(authorization_context=context) + + malformed_status, _malformed = self._http_raw( + "POST", "/period-closes", b"not-json", self.policy.tenant_reference + ) + non_object_status, _non_object = self._http_raw( + "POST", "/period-closes", b"[]", self.policy.tenant_reference + ) + + self.assertEqual(malformed_status, 400) + self.assertEqual(non_object_status, 400) + self.assertEqual(self._count_table("accounting_core.general_journal"), 0) + self.assertEqual(self._count_table("accounting_integration.outbox_event"), 0) + self.assertEqual( + self._count_table("accounting_integration.authorization_decision_record"), + 2, + ) + server.shutdown() + def test_authorization_evidence_retains_principal_and_requested_tenants(self) -> None: """A cross-tenant denial retains both sides of the attempted scope.""" principal_tenant = "urn:cwl:tenant_principal_other" From 89ae72d821d0c802251502af481b548df8881fd5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:00:03 +0900 Subject: [PATCH 15/55] test(auth): reserve reconciliation completion authority --- ...ation_completion_authorization_contract.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/test_reconciliation_completion_authorization_contract.py diff --git a/tests/test_reconciliation_completion_authorization_contract.py b/tests/test_reconciliation_completion_authorization_contract.py new file mode 100644 index 00000000..62f6541c --- /dev/null +++ b/tests/test_reconciliation_completion_authorization_contract.py @@ -0,0 +1,72 @@ +"""Authorization contracts for the reconciliation-completion buyer command.""" + +from __future__ import annotations + +import unittest + +from accounting_information_platform.authorization import ( + AuthenticatedPrincipal, + authorize, + permission_for_operation, + require_authorization, +) + + +_TENANT = "urn:cwl:tenant:reconciliation-auth" +_OPERATION = "complete_reconciliation" +_PERMISSION = "accounting.complete_reconciliation" + + +def _principal(*permissions: str, principal_kind: str = "human") -> AuthenticatedPrincipal: + """Return one trusted-adapter principal for focused authorization tests.""" + return AuthenticatedPrincipal( + principal_reference="urn:cwl:principal:controller", + tenant_reference=_TENANT, + authentication_context_reference="urn:cwl:authentication:oidc-session", + granted_permission_codes=frozenset(permissions), + purpose_code="reconciliation_close_review", + credential_evidence_reference="urn:cwl:evidence:credential-session", + principal_kind=principal_kind, + ) + + +class ReconciliationCompletionAuthorizationContractTests(unittest.TestCase): + """Reserve a distinct high-impact permission before exposing completion transport.""" + + def test_completion_operation_has_one_explicit_permission(self) -> None: + """The completion command must not inherit posting, close, or tenant authority.""" + self.assertEqual(permission_for_operation(_OPERATION), _PERMISSION) + decision = require_authorization( + _principal(_PERMISSION), + _TENANT, + _OPERATION, + ) + self.assertTrue(decision.allowed) + self.assertEqual(decision.permission_code, _PERMISSION) + self.assertEqual(decision.purpose_code, "reconciliation_close_review") + + def test_other_accounting_permissions_do_not_complete_reconciliation(self) -> None: + """Posting, hard-close, and read grants remain non-equivalent authorities.""" + for permission in ( + "accounting.post_proposal", + "accounting.hard_close_period", + "accounting.read_close", + ): + with self.subTest(permission=permission): + decision = authorize(_principal(permission), _TENANT, _OPERATION) + self.assertFalse(decision.allowed) + self.assertEqual(decision.permission_code, _PERMISSION) + + def test_agent_origin_is_denied_completion_even_with_copied_permission(self) -> None: + """Model/agent identity cannot promote itself into reconciliation approval authority.""" + decision = authorize( + _principal(_PERMISSION, principal_kind="agent"), + _TENANT, + _OPERATION, + ) + self.assertFalse(decision.allowed) + self.assertEqual(decision.permission_code, _PERMISSION) + + +if __name__ == "__main__": # pragma: no cover - direct invocation convenience + unittest.main() From 1593f2b6587ad551726001182baa42287504141a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:00:46 +0900 Subject: [PATCH 16/55] feat(auth): reserve reconciliation completion permission --- src/accounting_information_platform/authorization.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/accounting_information_platform/authorization.py b/src/accounting_information_platform/authorization.py index 100fc8ed..b5c29b0c 100644 --- a/src/accounting_information_platform/authorization.py +++ b/src/accounting_information_platform/authorization.py @@ -54,6 +54,7 @@ "open_period": "accounting.open_period", "soft_close_period": "accounting.soft_close_period", "hard_close_period": "accounting.hard_close_period", + "complete_reconciliation": "accounting.complete_reconciliation", "publish_outbox": "accounting.publish_outbox", "submit_tax_artifact": "accounting.submit_tax_artifact", "manage_bank_account": "accounting.manage_bank_account", @@ -69,6 +70,7 @@ "open_period", "soft_close_period", "hard_close_period", + "complete_reconciliation", "publish_outbox", "submit_tax_artifact", "manage_bank_account", @@ -261,4 +263,4 @@ def record_authorization_decision( decision.decision_code, correlation_reference, ), - ) + ) \ No newline at end of file From 9e6fa3ff0ac899e4a57f4ea72f2d0c8b3b8cc0f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:01:46 +0900 Subject: [PATCH 17/55] test(auth): version reconciliation permission expansion --- .../test_reconciliation_completion_authorization_contract.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_reconciliation_completion_authorization_contract.py b/tests/test_reconciliation_completion_authorization_contract.py index 62f6541c..ca0dbd4d 100644 --- a/tests/test_reconciliation_completion_authorization_contract.py +++ b/tests/test_reconciliation_completion_authorization_contract.py @@ -5,6 +5,7 @@ import unittest from accounting_information_platform.authorization import ( + AUTHORIZATION_POLICY_VERSION, AuthenticatedPrincipal, authorize, permission_for_operation, @@ -33,8 +34,9 @@ def _principal(*permissions: str, principal_kind: str = "human") -> Authenticate class ReconciliationCompletionAuthorizationContractTests(unittest.TestCase): """Reserve a distinct high-impact permission before exposing completion transport.""" - def test_completion_operation_has_one_explicit_permission(self) -> None: + def test_completion_operation_has_one_explicit_versioned_permission(self) -> None: """The completion command must not inherit posting, close, or tenant authority.""" + self.assertEqual(AUTHORIZATION_POLICY_VERSION, "accounting-authorization-v2") self.assertEqual(permission_for_operation(_OPERATION), _PERMISSION) decision = require_authorization( _principal(_PERMISSION), @@ -44,6 +46,7 @@ def test_completion_operation_has_one_explicit_permission(self) -> None: self.assertTrue(decision.allowed) self.assertEqual(decision.permission_code, _PERMISSION) self.assertEqual(decision.purpose_code, "reconciliation_close_review") + self.assertEqual(decision.policy_version, AUTHORIZATION_POLICY_VERSION) def test_other_accounting_permissions_do_not_complete_reconciliation(self) -> None: """Posting, hard-close, and read grants remain non-equivalent authorities.""" From f9814c09e11ca946bf217a85c0c1dec1163855ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:02:22 +0900 Subject: [PATCH 18/55] fix(auth): version expanded operation policy --- src/accounting_information_platform/authorization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/accounting_information_platform/authorization.py b/src/accounting_information_platform/authorization.py index b5c29b0c..f0c1200b 100644 --- a/src/accounting_information_platform/authorization.py +++ b/src/accounting_information_platform/authorization.py @@ -18,7 +18,7 @@ from .core import _require_code, _require_reference -AUTHORIZATION_POLICY_VERSION = "accounting-authorization-v1" +AUTHORIZATION_POLICY_VERSION = "accounting-authorization-v2" _PERMISSION_PATTERN = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$") _PRINCIPAL_KINDS = frozenset(("human", "service", "agent")) From 6222aac552ee9d710abd14c626c545b336eb90b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:03:26 +0900 Subject: [PATCH 19/55] docs(auth): version reconciliation completion authority --- docs/adr/0055-purpose-bound-authorization.md | 78 +++++++++++++++++--- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/docs/adr/0055-purpose-bound-authorization.md b/docs/adr/0055-purpose-bound-authorization.md index f0521e6f..b7da0f1b 100644 --- a/docs/adr/0055-purpose-bound-authorization.md +++ b/docs/adr/0055-purpose-bound-authorization.md @@ -7,16 +7,17 @@ Accepted ## Context Tenant authentication identifies the accounting scope but does not establish that a caller may -read a report, post a proposal, reverse a journal, close a period, publish an outbox event, or -submit tax evidence. PostgreSQL role and forced-RLS controls remain necessary database defenses, -but they do not replace an application decision made before a route invokes domain work. +read a report, post a proposal, reverse a journal, complete reconciliation review, close a period, +publish an outbox event, or submit tax evidence. PostgreSQL role and forced-RLS controls remain +necessary database defenses, but they do not replace an application decision made before a route +invokes domain work. -## Decision +The authorization contract must also evolve as a versioned policy. Adding a new high-impact +operation while continuing to emit the predecessor policy identifier would make immutable audit +evidence ambiguous: two different operation/permission sets would both claim the same policy +version. -The HTTP boundary classifies a period-close authorization operation only after -the request body is valid JSON object data. Malformed or non-object bodies are -rejected without recording an allowed hard-close decision, because they are not -accounting commands. +## Decision The trusted host identity adapter supplies an immutable `AuthenticatedPrincipal` containing only validated opaque principal, tenant, authentication-context, purpose, permission, and credential- @@ -30,6 +31,28 @@ permission decision fails closed with HTTP 403. Soft-close and hard-close have i permissions. Request-body fields, tenant headers, database GUCs, Billing documents, and model text cannot grant authority. +Malformed or non-object `/period-closes` bodies are conservatively classified as +`hard_close_period` for the authorization step instead of returning `None`. Therefore an +unauthorized caller receives 403 without reaching the close handler, while a caller that actually +holds hard-close authority receives the caller-useful 400 validation response only after durable +authorization-decision evidence has been written. Invalid structure never bypasses authorization +or writes journal/close/outbox facts. + +Authorization policy `accounting-authorization-v2` adds the high-impact operation +`complete_reconciliation` with the distinct permission `accounting.complete_reconciliation`. +Posting, read, soft/hard-close, bank-ingest, outbox and tax permissions do not imply this permission. +An `agent` principal is denied this operation by the same default high-impact restriction even if +its untrusted context contains a copied permission string. This policy entry is deliberately +reserved before a buyer-facing reconciliation-completion transport is introduced; registering the +operation grants no route and no database capability by itself. + +The reconciliation-completion application permission remains separate from the database +`accounting_reconciliation_completer` capability that is owned by the later reconciliation +completion migration. A trusted application allow decision and a tenant-bound runtime connection +with the purpose-limited database capability are both required once the route exists. Neither +control substitutes for the other, and reconciliation completion remains separate from fiscal- +period close authority. + Every routed decision is appended to the tenant-scoped, forced-RLS `accounting_integration.authorization_decision_record` table. The record keeps the policy version, decision, principal/purpose evidence, principal tenant, requested tenant, operation, required @@ -46,13 +69,20 @@ status until a trusted host adapter supplies a validated context to ## Consequences -- Catalog readers do not implicitly receive posting or close authority. +- Catalog readers do not implicitly receive posting, reconciliation-completion, or close authority. +- Reconciliation completion has its own permission and remains a high-impact operation denied to + model/agent principals by default. +- Extending the operation/permission registry changes the durable policy version, so audit rows can + identify which exact authorization vocabulary was evaluated. - A service or human principal can receive explicit permissions through the same host-neutral port. - Agent/model contexts are denied high-impact operations by default. - Authorization evidence is durable and tenant isolated, while journal and command evidence keeps its existing transaction boundaries. - Deployment must grant the runtime login INSERT access to the authorization evidence table and provision the host adapter before enabling accounting routes. +- The future reconciliation-completion transport must require both + `accounting.complete_reconciliation` and the separately provisioned database completion + capability; neither tenant authentication nor one of the other accounting permissions is enough. ## Alternatives rejected @@ -60,6 +90,10 @@ status until a trusted host adapter supplies a validated context to authority. - Reading permission claims from request JSON or model text would let an untrusted caller promote itself. +- Reusing `accounting.hard_close_period`, `accounting.post_proposal`, or a generic writer grant for + reconciliation completion would collapse distinct business authorities and weaken audit meaning. +- Adding the reconciliation operation without bumping `AUTHORIZATION_POLICY_VERSION` would make + immutable decision evidence unable to distinguish the predecessor and expanded policy sets. - Storing raw JWTs or full policy documents would add unnecessary secret and PII exposure. ## Evidence @@ -67,3 +101,29 @@ status until a trusted host adapter supplies a validated context to `src/accounting_information_platform/authorization.py` owns the immutable decision contract and `http_api.py` performs route mapping before domain dispatch. Migration `0015_authorization_decision_evidence.sql` owns tenant isolation and append-only audit evidence. +`tests/test_reconciliation_completion_authorization_contract.py` proves that reconciliation +completion has one explicit versioned permission, does not inherit posting/close/read authority, +and remains denied to agent principals by default. + +## Research and standards traceability + +Hu, V. C., Ferraiolo, D., Kuhn, D. R., Schnitzer, A., Sandlin, K., Miller, R., & Scarfone, K. +(2019). *Guide to attribute based access control (ABAC) definition and considerations* (NIST +Special Publication 800-162, updated August 2, 2019). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-162 + +Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* +(NIST Special Publication 800-53 Revision 5, Release 5.2.0 current as of August 27, 2025). +National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + +Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information +Security and Applications*. +https://consensus.app/papers/data-flow-security-in-rolebased-access-control-logrippo/95874bd5d780530a8e80eece583cda0e/?utm_source=chatgpt + +NIST SP 800-162 defines authorization in terms of subject/object/operation/environment attributes, +which supports keeping tenant identity, requested operation, purpose and principal kind as distinct +decision inputs. NIST SP 800-53 Rev. 5 AC-6 supports least privilege and purpose-limited roles and +process privileges. Logrippo (2025) provides a current formal RBAC integrity/data-flow basis for +reasoning about role/permission assignments and reconfiguration. These sources inform control +design only; they do not grant accounting authority or replace exact-head tests and deployment +evidence. From 5e2f74248b2f809e9ab33de28029d52a0674f316 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:06:29 +0900 Subject: [PATCH 20/55] docs(auth): doctor reconciliation completion policy expansion --- ...liation-completion-authorization-policy.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md diff --git a/docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md b/docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md new file mode 100644 index 00000000..090323f5 --- /dev/null +++ b/docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md @@ -0,0 +1,39 @@ +# Reconciliation-completion authorization policy expansion — 2026-09-01 + +## Scope + +This note records the application-authorization work needed before a buyer-facing reconciliation-completion route can be exposed. It does not consume mutable source from the stacked reconciliation-completion implementation and does not grant database authority. + +## RED → GREEN sequence + +1. Added `tests/test_reconciliation_completion_authorization_contract.py` requiring a dedicated `complete_reconciliation` operation mapped to `accounting.complete_reconciliation`. +2. The RED contract also proves `accounting.post_proposal`, `accounting.hard_close_period`, and `accounting.read_close` are non-equivalent permissions, and that an `agent` principal remains denied even if its context contains the new permission string. +3. Registered `complete_reconciliation` in `_OPERATION_PERMISSIONS` and `_HIGH_IMPACT_OPERATIONS`. +4. Strengthened the contract to require a new durable authorization policy identifier because the operation/permission vocabulary changed. +5. Bumped `AUTHORIZATION_POLICY_VERSION` to `accounting-authorization-v2` so immutable authorization-decision evidence does not claim the predecessor policy version for an expanded policy set. +6. Corrected ADR 0055's stale malformed-period-close prose: current source conservatively classifies malformed/non-object `/period-closes` bodies as `hard_close_period`, records authorization evidence, then returns either 403 before domain work or the caller-useful 400 validation response for a genuinely hard-close-authorized principal. Invalid structure cannot bypass authorization. + +## Authority boundary + +The application permission is not the PostgreSQL capability. In the later integrated product, reconciliation completion requires both: + +- an application allow decision for `accounting.complete_reconciliation` under the current versioned authorization policy; and +- a tenant-bound runtime identity possessing the separately owned purpose-limited PostgreSQL reconciliation-completion capability. + +Neither tenant authentication, a close/posting permission, a database GUC, a copied model/agent permission string, nor possession of only one layer is sufficient. The future HTTP route must invoke authorization before the reconciliation-completion command and retain the decision evidence independently of the command result. + +## Research and standards basis + +Hu et al.'s NIST SP 800-162 ABAC guidance models authorization as evaluation of subject, object/target, requested operation and contextual/environmental attributes against policy; this supports keeping principal kind, tenant target, purpose and requested accounting operation separate. NIST SP 800-53 Rev. 5 AC-6 requires least privilege for users and processes, supporting a distinct reconciliation-completion permission rather than reusing posting or close authority. Logrippo (2025) provides a current formal RBAC integrity/data-flow basis for reasoning about role/permission assignments and policy reconfiguration. + +### APA 7th references + +Hu, V. C., Ferraiolo, D., Kuhn, D. R., Schnitzer, A., Sandlin, K., Miller, R., & Scarfone, K. (2019). *Guide to attribute based access control (ABAC) definition and considerations* (NIST Special Publication 800-162, updated August 2, 2019). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-162 + +Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Revision 5; Release 5.2.0 current August 27, 2025). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + +Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information Security and Applications*. https://consensus.app/papers/data-flow-security-in-rolebased-access-control-logrippo/95874bd5d780530a8e80eece583cda0e/?utm_source=chatgpt + +## Integration boundary + +This sibling authorization branch remains blocked behind the reconciliation dependency root and must be restacked/revalidated against the exact protected integrated base. The documentation-owner branch also carries canonical release-history corrections that must be reconciled before merge. No predecessor check, review, or release claim transfers across that restack. From 270bc60a4918b3d10603dba3ad82b34919d86f6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:47:14 +0900 Subject: [PATCH 21/55] docs: canonicalize reconciliation authorization references --- docs/doctoring/REFERENCES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/doctoring/REFERENCES.md b/docs/doctoring/REFERENCES.md index 8fd3f3f0..3b8c736c 100644 --- a/docs/doctoring/REFERENCES.md +++ b/docs/doctoring/REFERENCES.md @@ -16,6 +16,8 @@ Google Open Source Security Team. (2026a). *OSV-Scanner: Project source scanning Google Open Source Security Team. (2026b). *OSV-Scanner: Supported artifacts and manifests*. https://google.github.io/osv-scanner/supported-languages-and-lockfiles/ +Hu, V. C., Ferraiolo, D., Kuhn, D. R., Schnitzer, A., Sandlin, K., Miller, R., & Scarfone, K. (2019). *Guide to attribute based access control (ABAC) definition and considerations* (NIST Special Publication 800-162, updated August 2, 2019). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-162 + IFRS Foundation. (2022). *IAS 1 presentation of financial statements*. https://www.ifrs.org/issued-standards/list-of-standards/ias-1-presentation-of-financial-statements/ IFRS Foundation. (2022). *IAS 7 statement of cash flows*. https://www.ifrs.org/issued-standards/list-of-standards/ias-7-statement-of-cash-flows/ @@ -48,10 +50,14 @@ Internet Engineering Task Force. (2022). *HTTP/1.1* (RFC 9112). https://www.rfc- Internet Engineering Task Force. (2024). *Universally unique IDentifiers (UUIDs)* (RFC 9562). https://www.rfc-editor.org/rfc/rfc9562 +Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Revision 5; Release 5.2.0 current August 27, 2025). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + Korea Legislation Research Institute. (2024b). *Income Tax Act* [Unofficial translation]. https://elaw.klri.re.kr/eng_service/lawView.do?hseq=51753&lang=ENG Korea Legislation Research Institute. (2024a). *Value-Added Tax Act* [Unofficial translation]. https://elaw.klri.re.kr/eng_service/lawView.do?hseq=53110&lang=ENG +Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information Security and Applications*. https://consensus.app/papers/data-flow-security-in-rolebased-access-control-logrippo/95874bd5d780530a8e80eece583cda0e/ + PostgreSQL Global Development Group. (2026). *PostgreSQL 18.4 release notes*. https://www.postgresql.org/docs/release/18.4/ PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Advisory locks*. https://www.postgresql.org/docs/18/functions-admin.html From 1f9da05f402fbe47e632ef7d8b22ea8405bf0d0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:19:38 +0900 Subject: [PATCH 22/55] test(auth): require request-scoped principal resolution --- ...st_request_scoped_authorization_context.py | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/test_request_scoped_authorization_context.py diff --git a/tests/test_request_scoped_authorization_context.py b/tests/test_request_scoped_authorization_context.py new file mode 100644 index 00000000..e982878b --- /dev/null +++ b/tests/test_request_scoped_authorization_context.py @@ -0,0 +1,115 @@ +"""Regression contracts for request-scoped authenticated principal resolution.""" + +from __future__ import annotations + +import inspect +import unittest +import unittest.mock as mock +from email.message import Message +from types import SimpleNamespace + +from accounting_information_platform.authorization import AuthenticatedPrincipal +from accounting_information_platform.http_api import ( + JournalProposalHandler, + create_journal_proposal_server, +) + + +_TENANT = "urn:cwl:tenant:request-auth" + + +def _principal(reference: str, *permissions: str) -> AuthenticatedPrincipal: + """Build one already-validated host identity for a transport-boundary test.""" + return AuthenticatedPrincipal( + principal_reference=f"urn:cwl:principal:{reference}", + tenant_reference=_TENANT, + authentication_context_reference=f"urn:cwl:authentication:{reference}", + granted_permission_codes=frozenset(permissions), + purpose_code="request_scope_test", + credential_evidence_reference=f"urn:cwl:evidence:{reference}", + principal_kind="human", + ) + + +class RequestScopedAuthorizationContextTests(unittest.TestCase): + """Prevent one server-wide principal from becoming every caller's identity.""" + + def _handler(self, resolver: object, identity: str) -> JournalProposalHandler: + """Construct a handler with one transport identity marker and resolver.""" + handler = object.__new__(JournalProposalHandler) + headers = Message() + headers.add_header("X-CWL-Tenant-Reference", _TENANT) + headers.add_header("X-Test-Validated-Identity", identity) + handler.headers = headers + handler.server = SimpleNamespace( + database_url="postgresql://unused", + tenant_reference=_TENANT, + request_principal_resolver=resolver, + ) + handler._write_error = mock.Mock() # type: ignore[method-assign] + return handler + + def test_public_server_factory_requires_a_request_principal_resolver(self) -> None: + """The production factory must not accept one static principal for all requests.""" + parameters = inspect.signature(create_journal_proposal_server).parameters + + self.assertIn("request_principal_resolver", parameters) + self.assertNotIn("authorization_context", parameters) + + def test_each_request_resolves_its_own_validated_principal(self) -> None: + """Two requests on one server cannot silently inherit the same caller authority.""" + reader = _principal("reader", "accounting.read_catalog") + observer = _principal("observer") + seen: list[str] = [] + + def resolver(request: JournalProposalHandler) -> AuthenticatedPrincipal | None: + identity = request.headers.get("X-Test-Validated-Identity", "") + seen.append(identity) + return {"reader": reader, "observer": observer}.get(identity) + + reader_handler = self._handler(resolver, "reader") + observer_handler = self._handler(resolver, "observer") + with mock.patch( + "accounting_information_platform.http_api.record_authorization_decision" + ) as record: + self.assertTrue( + JournalProposalHandler._authorize_request( + reader_handler, "read_catalog", "/legal-entities" + ) + ) + self.assertFalse( + JournalProposalHandler._authorize_request( + observer_handler, "read_catalog", "/legal-entities" + ) + ) + + self.assertEqual(seen, ["reader", "observer"]) + self.assertEqual(record.call_count, 2) + observer_handler._write_error.assert_called_once() + self.assertEqual(observer_handler._write_error.call_args.args[0], 403) + + def test_identity_adapter_failure_is_fail_closed_before_audit_allow(self) -> None: + """An unavailable trusted identity adapter cannot fall back to shared authority.""" + resolver = mock.Mock(side_effect=RuntimeError("identity provider unavailable")) + handler = self._handler(resolver, "reader") + + with mock.patch( + "accounting_information_platform.http_api.record_authorization_decision" + ) as record: + self.assertFalse( + JournalProposalHandler._authorize_request( + handler, "read_catalog", "/legal-entities" + ) + ) + + record.assert_not_called() + handler._write_error.assert_called_once() + self.assertEqual(handler._write_error.call_args.args[0], 503) + self.assertNotIn( + "identity provider unavailable", + handler._write_error.call_args.args[1], + ) + + +if __name__ == "__main__": + unittest.main() From 0fa27a8e2d068996a22da2c2a9b908e87f0bd8f7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:20:38 +0900 Subject: [PATCH 23/55] ci(auth): repair request-scoped caller identity boundary --- .../tmp-pr34-request-principal-green.yml | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 .github/workflows/tmp-pr34-request-principal-green.yml diff --git a/.github/workflows/tmp-pr34-request-principal-green.yml b/.github/workflows/tmp-pr34-request-principal-green.yml new file mode 100644 index 00000000..20f69591 --- /dev/null +++ b/.github/workflows/tmp-pr34-request-principal-green.yml @@ -0,0 +1,186 @@ +name: Temporary PR34 Request Principal GREEN + +on: + push: + branches: + - feat/purpose-bound-authorization + paths: + - .github/workflows/tmp-pr34-request-principal-green.yml + +permissions: + contents: read + +jobs: + repair: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout exact writer head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: feat/purpose-bound-authorization + fetch-depth: 1 + persist-credentials: false + + - name: Repair request-scoped identity and primary research links + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + python3 <<'PY' + from pathlib import Path + + def replace_exact(path: str, old: str, new: str, count: int = 1) -> None: + target = Path(path) + text = target.read_text(encoding="utf-8") + actual = text.count(old) + if actual != count: + raise SystemExit(f"{path}: expected {count} occurrences, found {actual}: {old!r}") + target.write_text(text.replace(old, new, count), encoding="utf-8") + + http = "src/accounting_information_platform/http_api.py" + replace_exact( + http, + " authorization_context: AuthenticatedPrincipal | None = None,\n", + " request_principal_resolver: Callable[[\\\"JournalProposalHandler\\\"], AuthenticatedPrincipal | None] | None = None,\n", + ) + replace_exact( + http, + " self.authorization_context = authorization_context\n", + " self.request_principal_resolver = request_principal_resolver\n", + ) + replace_exact( + http, + " decision = authorize(\n self.server.authorization_context,\n tenant_header,\n operation_code,\n )\n", + " resolver = self.server.request_principal_resolver\n try:\n principal = None if resolver is None else resolver(self)\n except Exception:\n self._write_error(\n 503,\n \\\"caller identity validation is unavailable. Ask the platform operator to restore the trusted identity adapter, then retry.\\\",\n )\n return False\n decision = authorize(\n principal,\n tenant_header,\n operation_code,\n )\n", + ) + replace_exact( + http, + " authorization_context: AuthenticatedPrincipal | None = None,\n) -> JournalProposalServer:\n", + " request_principal_resolver: Callable[[JournalProposalHandler], AuthenticatedPrincipal | None] | None = None,\n) -> JournalProposalServer:\n", + count=2, + ) + replace_exact( + http, + " (host, port), database_url, tenant_reference, authorization_context\n", + " (host, port), database_url, tenant_reference, request_principal_resolver\n", + ) + replace_exact( + http, + " authorization_context,\n )\n", + " request_principal_resolver,\n )\n", + ) + replace_exact( + http, + ' """Create a stdlib HTTP server that posts Billing proposals, AIS adjusting journals, pulls, closes, opens periods, accepts bank-statement evidence, and reads TB, statements, journals, reversals, receivable aging, payable aging, outbox, and audit history."""\n', + ' """Create an HTTP server whose trusted adapter resolves one principal per request."""\n', + ) + replace_exact( + http, + ' """Bind 127.0.0.1:$PORT by default and serve AIS HTTP commands."""\n', + ' """Bind 127.0.0.1:$PORT and resolve caller identity independently per request."""\n', + ) + + auth_test = "tests/test_authorization.py" + replace_exact( + auth_test, + ' authorization_context=principal("accounting.read_catalog"),\n', + ' request_principal_resolver=lambda _request: principal("accounting.read_catalog"),\n', + ) + + pg_test = "tests/test_postgres_posting.py" + replace_exact( + pg_test, + " authorization_context,\n )\n", + " request_principal_resolver=lambda _request: authorization_context,\n )\n", + ) + + replacements = { + "docs/OPERABILITY.md": [ + ( + "Pass that context explicitly to the server; the standalone runner supplies no principal and therefore\ndenies every accounting route except `/healthz`.", + "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`.", + ), + ], + "docs/SECURITY.md": [ + ( + "The trusted host identity adapter must validate issuer, audience, expiry, signature, and token binding\nbefore passing an `AuthenticatedPrincipal` to AIS.", + "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.", + ), + ], + "docs/ARCHITECTURE.md": [ + ( + "Production exposure therefore requires a trusted host or gateway that authenticates the caller before traffic reaches this process and supplies a validated `AuthenticatedPrincipal` with an explicit `human`, `service`, or `agent` kind.", + "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.", + ), + ], + "docs/adr/0055-purpose-bound-authorization.md": [ + ( + "The standalone runner has no authenticated principal by default and therefore exposes only health\nstatus until a trusted host adapter supplies a validated context to\n`create_journal_proposal_server` or `run_journal_proposal_server`.", + "The standalone runner has no request-principal resolver by default and therefore exposes only health status. A trusted host adapter integrates through `request_principal_resolver`; the resolver is invoked for each request and must return only that request's validated `AuthenticatedPrincipal`. The server never accepts one reusable authenticated principal as authority for every connected client.", + ), + ], + } + for path, pairs in replacements.items(): + for old, new in pairs: + replace_exact(path, old, new) + + doi = "https://doi.org/10.1016/j.jisa.2025.103997" + for path in ( + "docs/adr/0055-purpose-bound-authorization.md", + "docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md", + "docs/doctoring/REFERENCES.md", + ): + target = Path(path) + text = target.read_text(encoding="utf-8") + if "https://consensus.app/papers/data-flow-security-in-rolebased-access-control-logrippo/95874bd5d780530a8e80eece583cda0e/" not in text: + raise SystemExit(f"{path}: expected Consensus secondary-source URL") + start = "https://consensus.app/papers/data-flow-security-in-rolebased-access-control-logrippo/95874bd5d780530a8e80eece583cda0e/" + text = text.replace(start + "?utm_source=chatgpt", doi).replace(start, doi) + target.write_text(text, encoding="utf-8") + + changelog = Path("CHANGELOG.md") + text = changelog.read_text(encoding="utf-8") + marker = "## [Unreleased]\n\n" + if marker not in text: + raise SystemExit("CHANGELOG Unreleased marker missing") + note = ( + "- 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.\n" + ) + if note not in text: + text = text.replace(marker, marker + note, 1) + changelog.write_text(text, encoding="utf-8") + PY + rm -f .github/workflows/tmp-pr34-request-principal-green.yml + git diff --check + + - name: Verify focused request authorization GREEN + run: | + set -euo pipefail + PYTHONPATH=. python3 -m unittest -v \ + tests.test_authorization \ + tests.test_request_scoped_authorization_context \ + tests.test_reconciliation_completion_authorization_contract + PYTHONPATH=. python3 scripts/validate_repository.py . + python3 -m compileall -q src tests/test_request_scoped_authorization_context.py + test ! -e .github/workflows/tmp-pr34-request-principal-green.yml + git diff --check + + - name: Publish exact repaired source + env: + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo '::error::A non-GITHUB_TOKEN writer credential is required so synchronize-triggered checks run on the repaired head.' + exit 1 + fi + branch='feat/purpose-bound-authorization' + remote_head="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${branch}" --jq '.object.sha')" + test "$remote_head" = "$GITHUB_SHA" + git config user.name 'opencode-agent[bot]' + git config user.email '219766164+opencode-agent[bot]@users.noreply.github.com' + git add -A + git diff --cached --check + git commit -m 'fix(auth): resolve validated principal per request' + gh auth setup-git + git push origin HEAD:"$branch" From 8c561df87b855e695275fa2594b7b6737593974f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:24:32 +0900 Subject: [PATCH 24/55] ci(auth): use src-aware Python path for focused repair --- .github/workflows/tmp-pr34-request-principal-green.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tmp-pr34-request-principal-green.yml b/.github/workflows/tmp-pr34-request-principal-green.yml index 20f69591..85095bf1 100644 --- a/.github/workflows/tmp-pr34-request-principal-green.yml +++ b/.github/workflows/tmp-pr34-request-principal-green.yml @@ -156,12 +156,12 @@ jobs: - name: Verify focused request authorization GREEN run: | set -euo pipefail - PYTHONPATH=. python3 -m unittest -v \ + PYTHONPATH=src:. python3 -m unittest -v \ tests.test_authorization \ tests.test_request_scoped_authorization_context \ tests.test_reconciliation_completion_authorization_contract - PYTHONPATH=. python3 scripts/validate_repository.py . - python3 -m compileall -q src tests/test_request_scoped_authorization_context.py + PYTHONPATH=src:. python3 scripts/validate_repository.py . + PYTHONPATH=src:. python3 -m compileall -q src tests/test_request_scoped_authorization_context.py test ! -e .github/workflows/tmp-pr34-request-principal-green.yml git diff --check From 14684f99c2ab4eac599506822f4993f3e957c3ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:27:18 +0900 Subject: [PATCH 25/55] ci(auth): fix request-principal repair source quoting --- .github/workflows/tmp-pr34-request-principal-green.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tmp-pr34-request-principal-green.yml b/.github/workflows/tmp-pr34-request-principal-green.yml index 85095bf1..098e1230 100644 --- a/.github/workflows/tmp-pr34-request-principal-green.yml +++ b/.github/workflows/tmp-pr34-request-principal-green.yml @@ -41,7 +41,7 @@ jobs: replace_exact( http, " authorization_context: AuthenticatedPrincipal | None = None,\n", - " request_principal_resolver: Callable[[\\\"JournalProposalHandler\\\"], AuthenticatedPrincipal | None] | None = None,\n", + " request_principal_resolver: Callable[[object], AuthenticatedPrincipal | None] | None = None,\n", ) replace_exact( http, @@ -51,7 +51,7 @@ jobs: replace_exact( http, " decision = authorize(\n self.server.authorization_context,\n tenant_header,\n operation_code,\n )\n", - " resolver = self.server.request_principal_resolver\n try:\n principal = None if resolver is None else resolver(self)\n except Exception:\n self._write_error(\n 503,\n \\\"caller identity validation is unavailable. Ask the platform operator to restore the trusted identity adapter, then retry.\\\",\n )\n return False\n decision = authorize(\n principal,\n tenant_header,\n operation_code,\n )\n", + " resolver = self.server.request_principal_resolver\n try:\n principal = None if resolver is None else resolver(self)\n except Exception:\n self._write_error(\n 503,\n 'caller identity validation is unavailable. Ask the platform operator to restore the trusted identity adapter, then retry.',\n )\n return False\n decision = authorize(\n principal,\n tenant_header,\n operation_code,\n )\n", ) replace_exact( http, From 545da7d00215550b50c12c6632dbe6df93835a71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:31:23 +0900 Subject: [PATCH 26/55] ci(auth): allow bounded helper to publish verified repair --- .../tmp-pr34-request-principal-green.yml | 123 ++++-------------- 1 file changed, 24 insertions(+), 99 deletions(-) diff --git a/.github/workflows/tmp-pr34-request-principal-green.yml b/.github/workflows/tmp-pr34-request-principal-green.yml index 098e1230..38ac87f6 100644 --- a/.github/workflows/tmp-pr34-request-principal-green.yml +++ b/.github/workflows/tmp-pr34-request-principal-green.yml @@ -8,7 +8,7 @@ on: - .github/workflows/tmp-pr34-request-principal-green.yml permissions: - contents: read + contents: write jobs: repair: @@ -20,7 +20,7 @@ jobs: with: ref: feat/purpose-bound-authorization fetch-depth: 1 - persist-credentials: false + persist-credentials: true - name: Repair request-scoped identity and primary research links run: | @@ -38,114 +38,47 @@ jobs: target.write_text(text.replace(old, new, count), encoding="utf-8") http = "src/accounting_information_platform/http_api.py" - replace_exact( - http, - " authorization_context: AuthenticatedPrincipal | None = None,\n", - " request_principal_resolver: Callable[[object], AuthenticatedPrincipal | None] | None = None,\n", - ) - replace_exact( - http, - " self.authorization_context = authorization_context\n", - " self.request_principal_resolver = request_principal_resolver\n", - ) + replace_exact(http, " authorization_context: AuthenticatedPrincipal | None = None,\n", " request_principal_resolver: Callable[[object], AuthenticatedPrincipal | None] | None = None,\n") + replace_exact(http, " self.authorization_context = authorization_context\n", " self.request_principal_resolver = request_principal_resolver\n") replace_exact( http, " decision = authorize(\n self.server.authorization_context,\n tenant_header,\n operation_code,\n )\n", " resolver = self.server.request_principal_resolver\n try:\n principal = None if resolver is None else resolver(self)\n except Exception:\n self._write_error(\n 503,\n 'caller identity validation is unavailable. Ask the platform operator to restore the trusted identity adapter, then retry.',\n )\n return False\n decision = authorize(\n principal,\n tenant_header,\n operation_code,\n )\n", ) - replace_exact( - http, - " authorization_context: AuthenticatedPrincipal | None = None,\n) -> JournalProposalServer:\n", - " request_principal_resolver: Callable[[JournalProposalHandler], AuthenticatedPrincipal | None] | None = None,\n) -> JournalProposalServer:\n", - count=2, - ) - replace_exact( - http, - " (host, port), database_url, tenant_reference, authorization_context\n", - " (host, port), database_url, tenant_reference, request_principal_resolver\n", - ) - replace_exact( - http, - " authorization_context,\n )\n", - " request_principal_resolver,\n )\n", - ) - replace_exact( - http, - ' """Create a stdlib HTTP server that posts Billing proposals, AIS adjusting journals, pulls, closes, opens periods, accepts bank-statement evidence, and reads TB, statements, journals, reversals, receivable aging, payable aging, outbox, and audit history."""\n', - ' """Create an HTTP server whose trusted adapter resolves one principal per request."""\n', - ) - replace_exact( - http, - ' """Bind 127.0.0.1:$PORT by default and serve AIS HTTP commands."""\n', - ' """Bind 127.0.0.1:$PORT and resolve caller identity independently per request."""\n', - ) - - auth_test = "tests/test_authorization.py" - replace_exact( - auth_test, - ' authorization_context=principal("accounting.read_catalog"),\n', - ' request_principal_resolver=lambda _request: principal("accounting.read_catalog"),\n', - ) + replace_exact(http, " authorization_context: AuthenticatedPrincipal | None = None,\n) -> JournalProposalServer:\n", " request_principal_resolver: Callable[[JournalProposalHandler], AuthenticatedPrincipal | None] | None = None,\n) -> JournalProposalServer:\n", count=2) + replace_exact(http, " (host, port), database_url, tenant_reference, authorization_context\n", " (host, port), database_url, tenant_reference, request_principal_resolver\n") + replace_exact(http, " authorization_context,\n )\n", " request_principal_resolver,\n )\n") + replace_exact(http, ' """Create a stdlib HTTP server that posts Billing proposals, AIS adjusting journals, pulls, closes, opens periods, accepts bank-statement evidence, and reads TB, statements, journals, reversals, receivable aging, payable aging, outbox, and audit history."""\n', ' """Create an HTTP server whose trusted adapter resolves one principal per request."""\n') + replace_exact(http, ' """Bind 127.0.0.1:$PORT by default and serve AIS HTTP commands."""\n', ' """Bind 127.0.0.1:$PORT and resolve caller identity independently per request."""\n') - pg_test = "tests/test_postgres_posting.py" - replace_exact( - pg_test, - " authorization_context,\n )\n", - " request_principal_resolver=lambda _request: authorization_context,\n )\n", - ) + replace_exact("tests/test_authorization.py", ' authorization_context=principal("accounting.read_catalog"),\n', ' request_principal_resolver=lambda _request: principal("accounting.read_catalog"),\n') + replace_exact("tests/test_postgres_posting.py", " authorization_context,\n )\n", " request_principal_resolver=lambda _request: authorization_context,\n )\n") replacements = { - "docs/OPERABILITY.md": [ - ( - "Pass that context explicitly to the server; the standalone runner supplies no principal and therefore\ndenies every accounting route except `/healthz`.", - "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`.", - ), - ], - "docs/SECURITY.md": [ - ( - "The trusted host identity adapter must validate issuer, audience, expiry, signature, and token binding\nbefore passing an `AuthenticatedPrincipal` to AIS.", - "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.", - ), - ], - "docs/ARCHITECTURE.md": [ - ( - "Production exposure therefore requires a trusted host or gateway that authenticates the caller before traffic reaches this process and supplies a validated `AuthenticatedPrincipal` with an explicit `human`, `service`, or `agent` kind.", - "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.", - ), - ], - "docs/adr/0055-purpose-bound-authorization.md": [ - ( - "The standalone runner has no authenticated principal by default and therefore exposes only health\nstatus until a trusted host adapter supplies a validated context to\n`create_journal_proposal_server` or `run_journal_proposal_server`.", - "The standalone runner has no request-principal resolver by default and therefore exposes only health status. A trusted host adapter integrates through `request_principal_resolver`; the resolver is invoked for each request and must return only that request's validated `AuthenticatedPrincipal`. The server never accepts one reusable authenticated principal as authority for every connected client.", - ), - ], + "docs/OPERABILITY.md": [("Pass that context explicitly to the server; the standalone runner supplies no principal and therefore\ndenies every accounting route except `/healthz`.", "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`.")], + "docs/SECURITY.md": [("The trusted host identity adapter must validate issuer, audience, expiry, signature, and token binding\nbefore passing an `AuthenticatedPrincipal` to AIS.", "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.")], + "docs/ARCHITECTURE.md": [("Production exposure therefore requires a trusted host or gateway that authenticates the caller before traffic reaches this process and supplies a validated `AuthenticatedPrincipal` with an explicit `human`, `service`, or `agent` kind.", "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.")], + "docs/adr/0055-purpose-bound-authorization.md": [("The standalone runner has no authenticated principal by default and therefore exposes only health\nstatus until a trusted host adapter supplies a validated context to\n`create_journal_proposal_server` or `run_journal_proposal_server`.", "The standalone runner has no request-principal resolver by default and therefore exposes only health status. A trusted host adapter integrates through `request_principal_resolver`; the resolver is invoked for each request and must return only that request's validated `AuthenticatedPrincipal`. The server never accepts one reusable authenticated principal as authority for every connected client.")], } for path, pairs in replacements.items(): for old, new in pairs: replace_exact(path, old, new) doi = "https://doi.org/10.1016/j.jisa.2025.103997" - for path in ( - "docs/adr/0055-purpose-bound-authorization.md", - "docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md", - "docs/doctoring/REFERENCES.md", - ): + for path in ("docs/adr/0055-purpose-bound-authorization.md", "docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md", "docs/doctoring/REFERENCES.md"): target = Path(path) text = target.read_text(encoding="utf-8") - if "https://consensus.app/papers/data-flow-security-in-rolebased-access-control-logrippo/95874bd5d780530a8e80eece583cda0e/" not in text: - raise SystemExit(f"{path}: expected Consensus secondary-source URL") start = "https://consensus.app/papers/data-flow-security-in-rolebased-access-control-logrippo/95874bd5d780530a8e80eece583cda0e/" - text = text.replace(start + "?utm_source=chatgpt", doi).replace(start, doi) - target.write_text(text, encoding="utf-8") + if start not in text: + raise SystemExit(f"{path}: expected Consensus secondary-source URL") + target.write_text(text.replace(start + "?utm_source=chatgpt", doi).replace(start, doi), encoding="utf-8") changelog = Path("CHANGELOG.md") text = changelog.read_text(encoding="utf-8") marker = "## [Unreleased]\n\n" if marker not in text: raise SystemExit("CHANGELOG Unreleased marker missing") - note = ( - "- 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.\n" - ) + note = "- 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.\n" if note not in text: text = text.replace(marker, marker + note, 1) changelog.write_text(text, encoding="utf-8") @@ -156,10 +89,7 @@ jobs: - name: Verify focused request authorization GREEN run: | set -euo pipefail - PYTHONPATH=src:. python3 -m unittest -v \ - tests.test_authorization \ - tests.test_request_scoped_authorization_context \ - tests.test_reconciliation_completion_authorization_contract + PYTHONPATH=src:. python3 -m unittest -v tests.test_authorization tests.test_request_scoped_authorization_context tests.test_reconciliation_completion_authorization_contract PYTHONPATH=src:. python3 scripts/validate_repository.py . PYTHONPATH=src:. python3 -m compileall -q src tests/test_request_scoped_authorization_context.py test ! -e .github/workflows/tmp-pr34-request-principal-green.yml @@ -167,20 +97,15 @@ jobs: - name: Publish exact repaired source env: - GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - if [ -z "${GH_TOKEN:-}" ]; then - echo '::error::A non-GITHUB_TOKEN writer credential is required so synchronize-triggered checks run on the repaired head.' - exit 1 - fi branch='feat/purpose-bound-authorization' remote_head="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${branch}" --jq '.object.sha')" test "$remote_head" = "$GITHUB_SHA" - git config user.name 'opencode-agent[bot]' - git config user.email '219766164+opencode-agent[bot]@users.noreply.github.com' + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add -A git diff --cached --check git commit -m 'fix(auth): resolve validated principal per request' - gh auth setup-git git push origin HEAD:"$branch" From d882ed49c3cf2dd1dd0eb6e322472ed1685a4ea5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:33:11 +0000 Subject: [PATCH 27/55] fix(auth): resolve validated principal per request --- .../tmp-pr34-request-principal-green.yml | 111 ------------------ CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 2 +- docs/OPERABILITY.md | 3 +- docs/SECURITY.md | 3 +- docs/adr/0055-purpose-bound-authorization.md | 6 +- ...liation-completion-authorization-policy.md | 2 +- docs/doctoring/REFERENCES.md | 2 +- .../http_api.py | 27 +++-- tests/test_authorization.py | 2 +- tests/test_postgres_posting.py | 2 +- 11 files changed, 28 insertions(+), 133 deletions(-) delete mode 100644 .github/workflows/tmp-pr34-request-principal-green.yml diff --git a/.github/workflows/tmp-pr34-request-principal-green.yml b/.github/workflows/tmp-pr34-request-principal-green.yml deleted file mode 100644 index 38ac87f6..00000000 --- a/.github/workflows/tmp-pr34-request-principal-green.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Temporary PR34 Request Principal GREEN - -on: - push: - branches: - - feat/purpose-bound-authorization - paths: - - .github/workflows/tmp-pr34-request-principal-green.yml - -permissions: - contents: write - -jobs: - repair: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - name: Checkout exact writer head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: feat/purpose-bound-authorization - fetch-depth: 1 - persist-credentials: true - - - name: Repair request-scoped identity and primary research links - run: | - set -euo pipefail - test "$(git rev-parse HEAD)" = "$GITHUB_SHA" - python3 <<'PY' - from pathlib import Path - - def replace_exact(path: str, old: str, new: str, count: int = 1) -> None: - target = Path(path) - text = target.read_text(encoding="utf-8") - actual = text.count(old) - if actual != count: - raise SystemExit(f"{path}: expected {count} occurrences, found {actual}: {old!r}") - target.write_text(text.replace(old, new, count), encoding="utf-8") - - http = "src/accounting_information_platform/http_api.py" - replace_exact(http, " authorization_context: AuthenticatedPrincipal | None = None,\n", " request_principal_resolver: Callable[[object], AuthenticatedPrincipal | None] | None = None,\n") - replace_exact(http, " self.authorization_context = authorization_context\n", " self.request_principal_resolver = request_principal_resolver\n") - replace_exact( - http, - " decision = authorize(\n self.server.authorization_context,\n tenant_header,\n operation_code,\n )\n", - " resolver = self.server.request_principal_resolver\n try:\n principal = None if resolver is None else resolver(self)\n except Exception:\n self._write_error(\n 503,\n 'caller identity validation is unavailable. Ask the platform operator to restore the trusted identity adapter, then retry.',\n )\n return False\n decision = authorize(\n principal,\n tenant_header,\n operation_code,\n )\n", - ) - replace_exact(http, " authorization_context: AuthenticatedPrincipal | None = None,\n) -> JournalProposalServer:\n", " request_principal_resolver: Callable[[JournalProposalHandler], AuthenticatedPrincipal | None] | None = None,\n) -> JournalProposalServer:\n", count=2) - replace_exact(http, " (host, port), database_url, tenant_reference, authorization_context\n", " (host, port), database_url, tenant_reference, request_principal_resolver\n") - replace_exact(http, " authorization_context,\n )\n", " request_principal_resolver,\n )\n") - replace_exact(http, ' """Create a stdlib HTTP server that posts Billing proposals, AIS adjusting journals, pulls, closes, opens periods, accepts bank-statement evidence, and reads TB, statements, journals, reversals, receivable aging, payable aging, outbox, and audit history."""\n', ' """Create an HTTP server whose trusted adapter resolves one principal per request."""\n') - replace_exact(http, ' """Bind 127.0.0.1:$PORT by default and serve AIS HTTP commands."""\n', ' """Bind 127.0.0.1:$PORT and resolve caller identity independently per request."""\n') - - replace_exact("tests/test_authorization.py", ' authorization_context=principal("accounting.read_catalog"),\n', ' request_principal_resolver=lambda _request: principal("accounting.read_catalog"),\n') - replace_exact("tests/test_postgres_posting.py", " authorization_context,\n )\n", " request_principal_resolver=lambda _request: authorization_context,\n )\n") - - replacements = { - "docs/OPERABILITY.md": [("Pass that context explicitly to the server; the standalone runner supplies no principal and therefore\ndenies every accounting route except `/healthz`.", "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`.")], - "docs/SECURITY.md": [("The trusted host identity adapter must validate issuer, audience, expiry, signature, and token binding\nbefore passing an `AuthenticatedPrincipal` to AIS.", "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.")], - "docs/ARCHITECTURE.md": [("Production exposure therefore requires a trusted host or gateway that authenticates the caller before traffic reaches this process and supplies a validated `AuthenticatedPrincipal` with an explicit `human`, `service`, or `agent` kind.", "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.")], - "docs/adr/0055-purpose-bound-authorization.md": [("The standalone runner has no authenticated principal by default and therefore exposes only health\nstatus until a trusted host adapter supplies a validated context to\n`create_journal_proposal_server` or `run_journal_proposal_server`.", "The standalone runner has no request-principal resolver by default and therefore exposes only health status. A trusted host adapter integrates through `request_principal_resolver`; the resolver is invoked for each request and must return only that request's validated `AuthenticatedPrincipal`. The server never accepts one reusable authenticated principal as authority for every connected client.")], - } - for path, pairs in replacements.items(): - for old, new in pairs: - replace_exact(path, old, new) - - doi = "https://doi.org/10.1016/j.jisa.2025.103997" - for path in ("docs/adr/0055-purpose-bound-authorization.md", "docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md", "docs/doctoring/REFERENCES.md"): - target = Path(path) - text = target.read_text(encoding="utf-8") - start = "https://consensus.app/papers/data-flow-security-in-rolebased-access-control-logrippo/95874bd5d780530a8e80eece583cda0e/" - if start not in text: - raise SystemExit(f"{path}: expected Consensus secondary-source URL") - target.write_text(text.replace(start + "?utm_source=chatgpt", doi).replace(start, doi), encoding="utf-8") - - changelog = Path("CHANGELOG.md") - text = changelog.read_text(encoding="utf-8") - marker = "## [Unreleased]\n\n" - if marker not in text: - raise SystemExit("CHANGELOG Unreleased marker missing") - note = "- 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.\n" - if note not in text: - text = text.replace(marker, marker + note, 1) - changelog.write_text(text, encoding="utf-8") - PY - rm -f .github/workflows/tmp-pr34-request-principal-green.yml - git diff --check - - - name: Verify focused request authorization GREEN - run: | - set -euo pipefail - PYTHONPATH=src:. python3 -m unittest -v tests.test_authorization tests.test_request_scoped_authorization_context tests.test_reconciliation_completion_authorization_contract - PYTHONPATH=src:. python3 scripts/validate_repository.py . - PYTHONPATH=src:. python3 -m compileall -q src tests/test_request_scoped_authorization_context.py - test ! -e .github/workflows/tmp-pr34-request-principal-green.yml - git diff --check - - - name: Publish exact repaired source - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - branch='feat/purpose-bound-authorization' - remote_head="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${branch}" --jq '.object.sha')" - test "$remote_head" = "$GITHUB_SHA" - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git diff --cached --check - git commit -m 'fix(auth): resolve validated principal per request' - git push origin HEAD:"$branch" diff --git a/CHANGELOG.md b/CHANGELOG.md index 23143329..da949311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8757418d..305c3aad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -57,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 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 that authenticates the caller before traffic reaches this process and supplies a validated `AuthenticatedPrincipal` with an explicit `human`, `service`, or `agent` kind. 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. +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 diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index 64c555de..b153019e 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -11,8 +11,7 @@ Required environment values are deployment-specific. At minimum, configure the a 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. -Pass that context explicitly to the server; the standalone runner supplies no principal and therefore -denies every accounting route except `/healthz`. Grant the runtime login INSERT access to +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. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5a4bed32..f291f056 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -25,8 +25,7 @@ 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 -before passing an `AuthenticatedPrincipal` to AIS. It must pass the explicit `principal_kind` value +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, diff --git a/docs/adr/0055-purpose-bound-authorization.md b/docs/adr/0055-purpose-bound-authorization.md index b7da0f1b..ae05e9d9 100644 --- a/docs/adr/0055-purpose-bound-authorization.md +++ b/docs/adr/0055-purpose-bound-authorization.md @@ -63,9 +63,7 @@ accepts only unchanged decisions issued by the `authorize` evaluator, so a calle construct or mutate an `allowed` decision and promote it into durable evidence; copying an evaluator decision retains its provenance. -The standalone runner has no authenticated principal by default and therefore exposes only health -status until a trusted host adapter supplies a validated context to -`create_journal_proposal_server` or `run_journal_proposal_server`. +The standalone runner has no request-principal resolver by default and therefore exposes only health status. A trusted host adapter integrates through `request_principal_resolver`; the resolver is invoked for each request and must return only that request's validated `AuthenticatedPrincipal`. The server never accepts one reusable authenticated principal as authority for every connected client. ## Consequences @@ -118,7 +116,7 @@ National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP. Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information Security and Applications*. -https://consensus.app/papers/data-flow-security-in-rolebased-access-control-logrippo/95874bd5d780530a8e80eece583cda0e/?utm_source=chatgpt +https://doi.org/10.1016/j.jisa.2025.103997 NIST SP 800-162 defines authorization in terms of subject/object/operation/environment attributes, which supports keeping tenant identity, requested operation, purpose and principal kind as distinct diff --git a/docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md b/docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md index 090323f5..ec1cd42f 100644 --- a/docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md +++ b/docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md @@ -32,7 +32,7 @@ Hu, V. C., Ferraiolo, D., Kuhn, D. R., Schnitzer, A., Sandlin, K., Miller, R., & Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Revision 5; Release 5.2.0 current August 27, 2025). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 -Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information Security and Applications*. https://consensus.app/papers/data-flow-security-in-rolebased-access-control-logrippo/95874bd5d780530a8e80eece583cda0e/?utm_source=chatgpt +Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information Security and Applications*. https://doi.org/10.1016/j.jisa.2025.103997 ## Integration boundary diff --git a/docs/doctoring/REFERENCES.md b/docs/doctoring/REFERENCES.md index 3b8c736c..6c287951 100644 --- a/docs/doctoring/REFERENCES.md +++ b/docs/doctoring/REFERENCES.md @@ -56,7 +56,7 @@ Korea Legislation Research Institute. (2024b). *Income Tax Act* [Unofficial tran Korea Legislation Research Institute. (2024a). *Value-Added Tax Act* [Unofficial translation]. https://elaw.klri.re.kr/eng_service/lawView.do?hseq=53110&lang=ENG -Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information Security and Applications*. https://consensus.app/papers/data-flow-security-in-rolebased-access-control-logrippo/95874bd5d780530a8e80eece583cda0e/ +Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information Security and Applications*. https://doi.org/10.1016/j.jisa.2025.103997 PostgreSQL Global Development Group. (2026). *PostgreSQL 18.4 release notes*. https://www.postgresql.org/docs/release/18.4/ diff --git a/src/accounting_information_platform/http_api.py b/src/accounting_information_platform/http_api.py index e823a36e..a1a35d9f 100644 --- a/src/accounting_information_platform/http_api.py +++ b/src/accounting_information_platform/http_api.py @@ -189,12 +189,12 @@ def __init__( server_address: tuple[str, int], database_url: str, tenant_reference: str, - authorization_context: AuthenticatedPrincipal | None = None, + request_principal_resolver: Callable[[object], AuthenticatedPrincipal | None] | None = None, ) -> None: """Bind *server_address* to one tenant's posting endpoint.""" self.database_url = database_url self.tenant_reference = tenant_reference - self.authorization_context = authorization_context + self.request_principal_resolver = request_principal_resolver self.artifact_store = MemoryArtifactStore() super().__init__(server_address, JournalProposalHandler) @@ -1777,8 +1777,17 @@ def _authorize_request( tenant_header = self._bound_tenant_header(mismatch_action) if tenant_header is None: return False + resolver = self.server.request_principal_resolver + try: + principal = None if resolver is None else resolver(self) + except Exception: + self._write_error( + 503, + 'caller identity validation is unavailable. Ask the platform operator to restore the trusted identity adapter, then retry.', + ) + return False decision = authorize( - self.server.authorization_context, + principal, tenant_header, operation_code, ) @@ -1962,16 +1971,16 @@ def create_journal_proposal_server( tenant_reference: str, host: str = "127.0.0.1", port: int = 0, - authorization_context: AuthenticatedPrincipal | None = None, + request_principal_resolver: Callable[[JournalProposalHandler], AuthenticatedPrincipal | None] | None = None, ) -> JournalProposalServer: - """Create a stdlib HTTP server that posts Billing proposals, AIS adjusting journals, pulls, closes, opens periods, accepts bank-statement evidence, and reads TB, statements, journals, reversals, receivable aging, payable aging, outbox, and audit history.""" + """Create an HTTP server whose trusted adapter resolves one principal per request.""" if not database_url: raise AccountingValidationError( "ACCOUNTING_DATABASE_URL is empty. Set a PostgreSQL 18 URL and retry posting." ) _require_reference(tenant_reference, "tenant reference") return JournalProposalServer( - (host, port), database_url, tenant_reference, authorization_context + (host, port), database_url, tenant_reference, request_principal_resolver ) @@ -1981,9 +1990,9 @@ def run_journal_proposal_server( host: str | None = None, port: int | None = None, serve: Callable[[], None] | None = None, - authorization_context: AuthenticatedPrincipal | None = None, + request_principal_resolver: Callable[[JournalProposalHandler], AuthenticatedPrincipal | None] | None = None, ) -> JournalProposalServer: - """Bind 127.0.0.1:$PORT by default and serve AIS HTTP commands.""" + """Bind 127.0.0.1:$PORT and resolve caller identity independently per request.""" resolved_url = ( database_url if database_url is not None @@ -2010,7 +2019,7 @@ def run_journal_proposal_server( resolved_tenant, resolved_host, resolved_port, - authorization_context, + request_principal_resolver, ) runner = server.serve_forever if serve is None else serve runner() diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 73f6f847..1892c4fd 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -150,7 +150,7 @@ def test_http_authorization_evidence_failure_is_fail_closed(self) -> None: handler.server = SimpleNamespace( database_url="postgresql://unused", tenant_reference=TENANT, - authorization_context=principal("accounting.read_catalog"), + request_principal_resolver=lambda _request: principal("accounting.read_catalog"), ) handler._write_error = mock.Mock() # type: ignore[method-assign] with mock.patch( diff --git a/tests/test_postgres_posting.py b/tests/test_postgres_posting.py index 2c49655a..9ca4077e 100644 --- a/tests/test_postgres_posting.py +++ b/tests/test_postgres_posting.py @@ -13443,7 +13443,7 @@ def _start_http_server( bound_tenant, "127.0.0.1", 0, - authorization_context, + request_principal_resolver=lambda _request: authorization_context, ) thread = Thread(target=server.serve_forever, daemon=True) thread.start() From 5a985ef2284f0c643c9da98d5e92918f9b5745e5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 00:34:26 +0900 Subject: [PATCH 28/55] docs(auth): trace request-scoped principal authority repair --- ...9-01-request-scoped-principal-authority.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/doctoring/2026-09-01-request-scoped-principal-authority.md diff --git a/docs/doctoring/2026-09-01-request-scoped-principal-authority.md b/docs/doctoring/2026-09-01-request-scoped-principal-authority.md new file mode 100644 index 00000000..423eee23 --- /dev/null +++ b/docs/doctoring/2026-09-01-request-scoped-principal-authority.md @@ -0,0 +1,46 @@ +# Doctoring record: request-scoped principal authority + +**Date:** 2026-09-01 +**Scope:** purpose-bound accounting HTTP authorization boundary + +## Research question + +Can a multithreaded accounting HTTP server safely keep one validated `AuthenticatedPrincipal` on the server object and reuse that principal for every request, or must authenticated identity be resolved independently for each request before purpose-bound authorization? + +## Finding + +A server-wide authenticated principal collapses authentication and transport-session boundaries. Any client able to reach the server and satisfy the tenant route binding inherits the permissions, purpose, principal provenance, and credential evidence of the principal cached on the shared server object. `ThreadingHTTPServer` makes the defect especially explicit: multiple independent requests are handled through one server instance, so server-owned caller identity is not request-owned security context. + +The repaired contract therefore accepts a trusted `request_principal_resolver` rather than a reusable `authorization_context`. `_authorize_request()` invokes that resolver for every request after tenant binding and before the authorization decision is evaluated. The trusted host adapter remains responsible for validating signature, issuer, audience, expiry, token binding, and any deployment-specific credential/session evidence. AIS receives only the resulting host-neutral `AuthenticatedPrincipal` and never promotes request-body, document, header, or model content into authority. + +If the trusted identity adapter is unavailable or raises unexpectedly, the route fails closed with a generic 503 before any allow-shaped authorization evidence is recorded. A missing resolver produces an ordinary denied authorization decision rather than inheriting a previous caller. Authorization-decision persistence remains fail-closed as well. + +## RED → GREEN traceability + +| Requirement | Evidence | +| --- | --- | +| Public server construction cannot install one principal for all callers | `tests/test_request_scoped_authorization_context.py::test_public_server_factory_requires_a_request_principal_resolver` | +| Independent requests on one server obtain independent principals and permissions | `tests/test_request_scoped_authorization_context.py::test_each_request_resolves_its_own_validated_principal` | +| Identity-adapter outage cannot fall back to cached/shared authority | `tests/test_request_scoped_authorization_context.py::test_identity_adapter_failure_is_fail_closed_before_audit_allow` | +| Existing purpose/permission and audit behavior remains intact | `tests/test_authorization.py` plus the real PostgreSQL authorization tests exercised by Accounting Foundation CI | +| Repository documentation exposes the same boundary | `docs/ARCHITECTURE.md`, `docs/SECURITY.md`, `docs/OPERABILITY.md`, ADR 0055, and `CHANGELOG.md` | + +Focused exact-source verification during the bounded repair passed 25 authorization tests, repository validation, and Python compilation with `PYTHONPATH=src:.`. The normalized connector commit that adds this doctoring record is intentionally separate so ordinary synchronize-triggered exact-head workflows can validate the final source after the temporary repair workflow self-deleted. + +## DDD and authority boundary + +Identity/Policy remains a foreign bounded context supplied by Keyverse or another trusted host adapter. Accounting is the policy-enforcement point for accounting operations. `AuthenticatedPrincipal` is an anti-corruption value object carrying only already-validated authority attributes needed by AIS. It is request-scoped evidence, not a singleton, server configuration, tenant identity, or domain aggregate. + +This repair does not grant posting, reversal, reconciliation approval, period-close, tax-submission, outbox-publication, or policy-change authority. It prevents one validated caller from becoming the implicit authority of unrelated requests. High-impact operations still require their explicit purpose-bound permission and immutable allow/deny evidence. + +## Research basis + +Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information Security and Applications, 91*, 103997. https://doi.org/10.1016/j.jisa.2025.103997 + +National Institute of Standards and Technology. (2014). *Guide to attribute based access control (ABAC) definition and considerations (NIST Special Publication 800-162)*. https://doi.org/10.6028/NIST.SP.800-162 + +Python Software Foundation. (2026). *http.server — HTTP servers: ThreadingHTTPServer*. https://docs.python.org/3/library/http.server.html + +## Evidence rule + +The focused repair result proves the source transformation only. Merge or release authority requires the final unchanged head to reacquire every then-applicable repository and organization check, dependency/security evidence, coverage and repository contracts, and qualifying current-head review. No predecessor workflow result is transferred. \ No newline at end of file From a55fff4daac10255399ba1553fb4af97fc235927 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:33:19 +0900 Subject: [PATCH 29/55] docs(auth): normalize doctoring whitespace --- .../2026-09-01-request-scoped-principal-authority.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/2026-09-01-request-scoped-principal-authority.md b/docs/doctoring/2026-09-01-request-scoped-principal-authority.md index 423eee23..8182f63f 100644 --- a/docs/doctoring/2026-09-01-request-scoped-principal-authority.md +++ b/docs/doctoring/2026-09-01-request-scoped-principal-authority.md @@ -1,6 +1,6 @@ # Doctoring record: request-scoped principal authority -**Date:** 2026-09-01 +**Date:** 2026-09-01 **Scope:** purpose-bound accounting HTTP authorization boundary ## Research question @@ -43,4 +43,4 @@ Python Software Foundation. (2026). *http.server — HTTP servers: ThreadingHTTP ## Evidence rule -The focused repair result proves the source transformation only. Merge or release authority requires the final unchanged head to reacquire every then-applicable repository and organization check, dependency/security evidence, coverage and repository contracts, and qualifying current-head review. No predecessor workflow result is transferred. \ No newline at end of file +The focused repair result proves the source transformation only. Merge or release authority requires the final unchanged head to reacquire every then-applicable repository and organization check, dependency/security evidence, coverage and repository contracts, and qualifying current-head review. No predecessor workflow result is transferred. From 5f85ab96ffaa4314cba3a1514a3b41c92f7373fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:35:25 +0900 Subject: [PATCH 30/55] test(auth): reject malformed principal resolver output --- ...st_request_scoped_authorization_context.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_request_scoped_authorization_context.py b/tests/test_request_scoped_authorization_context.py index e982878b..6d2da810 100644 --- a/tests/test_request_scoped_authorization_context.py +++ b/tests/test_request_scoped_authorization_context.py @@ -110,6 +110,28 @@ def test_identity_adapter_failure_is_fail_closed_before_audit_allow(self) -> Non handler._write_error.call_args.args[1], ) + def test_malformed_identity_adapter_output_is_fail_closed_before_audit_allow(self) -> None: + """Malformed trusted-adapter output must return 503 instead of dropping the request.""" + resolver = mock.Mock(return_value=object()) + handler = self._handler(resolver, "reader") + + with mock.patch( + "accounting_information_platform.http_api.record_authorization_decision" + ) as record: + self.assertFalse( + JournalProposalHandler._authorize_request( + handler, "read_catalog", "/legal-entities" + ) + ) + + record.assert_not_called() + handler._write_error.assert_called_once() + self.assertEqual(handler._write_error.call_args.args[0], 503) + self.assertIn( + "trusted identity adapter", + handler._write_error.call_args.args[1], + ) + if __name__ == "__main__": unittest.main() From 143397541697bbf63013e132cf9a239778897ce7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 01:35:46 +0900 Subject: [PATCH 31/55] ci: run bounded malformed-principal repair --- .../tmp-pr34-malformed-principal-green.yml | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/tmp-pr34-malformed-principal-green.yml diff --git a/.github/workflows/tmp-pr34-malformed-principal-green.yml b/.github/workflows/tmp-pr34-malformed-principal-green.yml new file mode 100644 index 00000000..05e096f2 --- /dev/null +++ b/.github/workflows/tmp-pr34-malformed-principal-green.yml @@ -0,0 +1,71 @@ +name: Temporary PR 34 malformed-principal repair + +on: + push: + branches: + - feat/purpose-bound-authorization + paths: + - .github/workflows/tmp-pr34-malformed-principal-green.yml + +permissions: + contents: write + +concurrency: + group: tmp-pr34-malformed-principal-green + cancel-in-progress: false + +jobs: + repair: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Check out exact repair head + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + fetch-depth: 0 + + - name: Prove RED, repair, validate, self-delete + shell: bash + env: + EXPECTED_REF: feat/purpose-bound-authorization + run: | + set -euo pipefail + test "${GITHUB_REF_NAME}" = "${EXPECTED_REF}" + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + + if PYTHONPATH=src:. python -m unittest -v \ + tests.test_request_scoped_authorization_context.RequestScopedAuthorizationContextTests.test_malformed_identity_adapter_output_is_fail_closed_before_audit_allow; then + echo 'Expected malformed-principal regression to be RED before production repair.' >&2 + exit 1 + fi + + python - <<'PY' + from pathlib import Path + + path = Path('src/accounting_information_platform/http_api.py') + text = path.read_text(encoding='utf-8') + old = ''' resolver = self.server.request_principal_resolver\n try:\n principal = None if resolver is None else resolver(self)\n except Exception:\n self._write_error(\n 503,\n 'caller identity validation is unavailable. Ask the platform operator to restore the trusted identity adapter, then retry.',\n )\n return False\n decision = authorize(\n principal,\n tenant_header,\n operation_code,\n )\n''' + new = ''' resolver = self.server.request_principal_resolver\n try:\n principal = None if resolver is None else resolver(self)\n decision = authorize(\n principal,\n tenant_header,\n operation_code,\n )\n except Exception:\n self._write_error(\n 503,\n 'caller identity validation is unavailable. Ask the platform operator to restore the trusted identity adapter, then retry.',\n )\n return False\n''' + if text.count(old) != 1: + raise SystemExit('unexpected _authorize_request source shape; refusing mutation') + path.write_text(text.replace(old, new, 1), encoding='utf-8') + PY + + PYTHONPATH=src:. python -m unittest -v tests.test_request_scoped_authorization_context + PYTHONPATH=src:. python -m unittest -v tests.test_authorization + PYTHONPATH=src:. python scripts/validate_repository.py . + python -m compileall -q src tests + git diff --check + + rm .github/workflows/tmp-pr34-malformed-principal-green.yml + test ! -e .github/workflows/tmp-pr34-malformed-principal-green.yml + git diff --check + + git config user.name 'contextualwisdomlab-automation' + git config user.email 'contextualwisdomlab-automation@users.noreply.github.com' + git add src/accounting_information_platform/http_api.py \ + .github/workflows/tmp-pr34-malformed-principal-green.yml + git commit -m 'fix(auth): fail closed on malformed principal output' + git fetch origin "${EXPECTED_REF}" + test "$(git rev-parse FETCH_HEAD)" = "${GITHUB_SHA}" + git push origin "HEAD:${EXPECTED_REF}" From 811aaf746ef03d10265de107a1dfe2868f91441d Mon Sep 17 00:00:00 2001 From: contextualwisdomlab-automation Date: Tue, 1 Sep 2026 16:36:08 +0000 Subject: [PATCH 32/55] fix(auth): fail closed on malformed principal output --- .../tmp-pr34-malformed-principal-green.yml | 71 ------------------- .../http_api.py | 10 +-- 2 files changed, 5 insertions(+), 76 deletions(-) delete mode 100644 .github/workflows/tmp-pr34-malformed-principal-green.yml diff --git a/.github/workflows/tmp-pr34-malformed-principal-green.yml b/.github/workflows/tmp-pr34-malformed-principal-green.yml deleted file mode 100644 index 05e096f2..00000000 --- a/.github/workflows/tmp-pr34-malformed-principal-green.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Temporary PR 34 malformed-principal repair - -on: - push: - branches: - - feat/purpose-bound-authorization - paths: - - .github/workflows/tmp-pr34-malformed-principal-green.yml - -permissions: - contents: write - -concurrency: - group: tmp-pr34-malformed-principal-green - cancel-in-progress: false - -jobs: - repair: - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - name: Check out exact repair head - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - with: - fetch-depth: 0 - - - name: Prove RED, repair, validate, self-delete - shell: bash - env: - EXPECTED_REF: feat/purpose-bound-authorization - run: | - set -euo pipefail - test "${GITHUB_REF_NAME}" = "${EXPECTED_REF}" - test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" - - if PYTHONPATH=src:. python -m unittest -v \ - tests.test_request_scoped_authorization_context.RequestScopedAuthorizationContextTests.test_malformed_identity_adapter_output_is_fail_closed_before_audit_allow; then - echo 'Expected malformed-principal regression to be RED before production repair.' >&2 - exit 1 - fi - - python - <<'PY' - from pathlib import Path - - path = Path('src/accounting_information_platform/http_api.py') - text = path.read_text(encoding='utf-8') - old = ''' resolver = self.server.request_principal_resolver\n try:\n principal = None if resolver is None else resolver(self)\n except Exception:\n self._write_error(\n 503,\n 'caller identity validation is unavailable. Ask the platform operator to restore the trusted identity adapter, then retry.',\n )\n return False\n decision = authorize(\n principal,\n tenant_header,\n operation_code,\n )\n''' - new = ''' resolver = self.server.request_principal_resolver\n try:\n principal = None if resolver is None else resolver(self)\n decision = authorize(\n principal,\n tenant_header,\n operation_code,\n )\n except Exception:\n self._write_error(\n 503,\n 'caller identity validation is unavailable. Ask the platform operator to restore the trusted identity adapter, then retry.',\n )\n return False\n''' - if text.count(old) != 1: - raise SystemExit('unexpected _authorize_request source shape; refusing mutation') - path.write_text(text.replace(old, new, 1), encoding='utf-8') - PY - - PYTHONPATH=src:. python -m unittest -v tests.test_request_scoped_authorization_context - PYTHONPATH=src:. python -m unittest -v tests.test_authorization - PYTHONPATH=src:. python scripts/validate_repository.py . - python -m compileall -q src tests - git diff --check - - rm .github/workflows/tmp-pr34-malformed-principal-green.yml - test ! -e .github/workflows/tmp-pr34-malformed-principal-green.yml - git diff --check - - git config user.name 'contextualwisdomlab-automation' - git config user.email 'contextualwisdomlab-automation@users.noreply.github.com' - git add src/accounting_information_platform/http_api.py \ - .github/workflows/tmp-pr34-malformed-principal-green.yml - git commit -m 'fix(auth): fail closed on malformed principal output' - git fetch origin "${EXPECTED_REF}" - test "$(git rev-parse FETCH_HEAD)" = "${GITHUB_SHA}" - git push origin "HEAD:${EXPECTED_REF}" diff --git a/src/accounting_information_platform/http_api.py b/src/accounting_information_platform/http_api.py index a1a35d9f..c53f17e4 100644 --- a/src/accounting_information_platform/http_api.py +++ b/src/accounting_information_platform/http_api.py @@ -1780,17 +1780,17 @@ def _authorize_request( resolver = self.server.request_principal_resolver try: principal = None if resolver is None else resolver(self) + decision = authorize( + principal, + tenant_header, + operation_code, + ) except Exception: self._write_error( 503, 'caller identity validation is unavailable. Ask the platform operator to restore the trusted identity adapter, then retry.', ) return False - decision = authorize( - principal, - tenant_header, - operation_code, - ) try: record_authorization_decision( self.server.database_url, From fa0ac2256574053fa8b9bb08565c052cb28b40a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:35:09 +0900 Subject: [PATCH 33/55] docs(auth): separate doctoring date and scope --- docs/doctoring/2026-09-01-request-scoped-principal-authority.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/doctoring/2026-09-01-request-scoped-principal-authority.md b/docs/doctoring/2026-09-01-request-scoped-principal-authority.md index 8182f63f..b1d4438d 100644 --- a/docs/doctoring/2026-09-01-request-scoped-principal-authority.md +++ b/docs/doctoring/2026-09-01-request-scoped-principal-authority.md @@ -1,6 +1,7 @@ # Doctoring record: request-scoped principal authority **Date:** 2026-09-01 + **Scope:** purpose-bound accounting HTTP authorization boundary ## Research question From 772a816f0f215f90f3e3abb65fa5f01a740ca6e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:35:59 +0900 Subject: [PATCH 34/55] fix(auth): bound durable decision evidence --- .../0015_authorization_decision_evidence.sql | 64 ++++++++++++++++--- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/database/migrations/0015_authorization_decision_evidence.sql b/database/migrations/0015_authorization_decision_evidence.sql index 74a6594f..96a3327b 100644 --- a/database/migrations/0015_authorization_decision_evidence.sql +++ b/database/migrations/0015_authorization_decision_evidence.sql @@ -2,22 +2,66 @@ 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 +-- correlation evidence ceiling is enforced again at PostgreSQL so direct SQL cannot inflate storage. 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) <> ''), - principal_tenant_reference text NOT NULL CHECK (btrim(principal_tenant_reference) <> ''), - requested_tenant_reference text NOT NULL CHECK (btrim(requested_tenant_reference) <> ''), + 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) <> ''), + 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) <> ''), - operation_code text NOT NULL CHECK (btrim(operation_code) <> ''), - permission_code text NOT NULL, - purpose_code text NOT NULL CHECK (btrim(purpose_code) <> ''), - policy_version text NOT NULL CHECK (btrim(policy_version) <> ''), + 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) <> ''), + correlation_reference text NOT NULL + CHECK (btrim(correlation_reference) <> '' AND octet_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), From 6ef23db8822a37f00f1402a74dbfcc70c7ea7f31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:36:17 +0900 Subject: [PATCH 35/55] test(auth): ratchet audit evidence bounds --- ...authorization_evidence_storage_contract.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_authorization_evidence_storage_contract.py diff --git a/tests/test_authorization_evidence_storage_contract.py b/tests/test_authorization_evidence_storage_contract.py new file mode 100644 index 00000000..c6c769d2 --- /dev/null +++ b/tests/test_authorization_evidence_storage_contract.py @@ -0,0 +1,53 @@ +"""Repository contracts for bounded durable authorization-decision evidence.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + + +MIGRATION = Path("database/migrations/0015_authorization_decision_evidence.sql") + + +class AuthorizationEvidenceStorageContractTests(unittest.TestCase): + """Keep every caller-derived authorization text field bounded at PostgreSQL.""" + + def test_identity_references_are_bounded_cwl_urns(self) -> None: + """Normalized identity references cannot become unbounded durable audit payloads.""" + text = MIGRATION.read_text(encoding="utf-8") + reference_columns = ( + "principal_reference", + "principal_tenant_reference", + "requested_tenant_reference", + "authentication_context_reference", + "credential_evidence_reference", + ) + + for column in reference_columns: + with self.subTest(column=column): + column_block = text.split(f"{column} text NOT NULL", 1)[1].split(",\n", 1)[0] + self.assertIn(f"octet_length({column}) <= 255", column_block) + self.assertIn( + f"{column} ~ '^urn:cwl:[A-Za-z0-9_:.-]+$'", + column_block, + ) + + def test_decision_vocabulary_and_correlation_are_bounded(self) -> None: + """Direct SQL cannot bypass the application vocabulary or correlation size budgets.""" + text = MIGRATION.read_text(encoding="utf-8") + + self.assertIn("octet_length(operation_code) <= 64", text) + self.assertIn("octet_length(permission_code) <= 129", text) + self.assertIn("octet_length(purpose_code) <= 64", text) + self.assertIn("octet_length(policy_version) <= 64", text) + self.assertIn("octet_length(correlation_reference) <= 512", text) + self.assertIn("operation_code ~ '^[a-z][a-z0-9_]{1,63}$'", text) + self.assertIn( + "permission_code ~ '^[a-z][a-z0-9_]{1,63}\\.[a-z][a-z0-9_]{1,63}$'", + text, + ) + self.assertIn("purpose_code ~ '^[a-z][a-z0-9_]{1,63}$'", text) + + +if __name__ == "__main__": # pragma: no cover - direct local invocation only + unittest.main() From 8b97fece602cac0e24ca74f587a4f52be4846794 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:36:38 +0900 Subject: [PATCH 36/55] docs(auth): record evidence storage bound basis --- ...2-authorization-evidence-storage-bounds.md | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/doctoring/2026-09-02-authorization-evidence-storage-bounds.md diff --git a/docs/doctoring/2026-09-02-authorization-evidence-storage-bounds.md b/docs/doctoring/2026-09-02-authorization-evidence-storage-bounds.md new file mode 100644 index 00000000..e43a5a84 --- /dev/null +++ b/docs/doctoring/2026-09-02-authorization-evidence-storage-bounds.md @@ -0,0 +1,44 @@ +# Doctoring record: bounded authorization evidence + +**Date:** 2026-09-02 + +**Scope:** durable purpose-bound authorization-decision evidence + +## Research question + +How should AIS prevent authenticated identity metadata from becoming an unbounded PostgreSQL audit-storage input without retaining bearer material or inventing a probabilistic truncation rule? + +## Decision + +The trusted identity adapter continues to validate external credentials and supplies only normalized opaque CWL references to AIS. Durable authorization evidence now treats those references as a bounded internal protocol rather than as arbitrary identity-provider strings. + +Each normalized identity reference persisted by `authorization_decision_record` is an ASCII `urn:cwl:` reference with an authorization-profile ceiling of 255 octets. This is a CWL profile decision, not a claim that every OIDC field is limited to 255 characters. The ceiling aligns the normalized principal identifier budget with the normative OpenID Connect `sub` ceiling: OpenID Connect Core specifies that the Subject Identifier must not exceed 255 ASCII characters. If a provider-native identifier plus CWL namespace material cannot fit the normalized profile, the trusted adapter must derive a stable opaque reference rather than copying an oversized raw claim into the accounting audit store. + +RFC 8141 permits a URN namespace definition to specify its own syntax and interoperability constraints. AIS therefore constrains its internal `urn:cwl:` authorization references at the anti-corruption boundary while leaving provider-native claim representation in the identity system that owns it. + +The remaining text budgets are derived from executable contracts rather than free-form estimates: operation and purpose codes follow the existing 64-octet code grammar; a permission is two such code components plus the separator (129 octets); `policy_version` is a bounded release identifier (64 octets); and `correlation_reference` mirrors the existing 512-octet HTTP evidence contract. PostgreSQL repeats these bounds so a privileged/direct SQL path cannot bypass the application checks and inflate append-only evidence. + +## DDD and security boundary + +Identity/Policy is a foreign bounded context. `AuthenticatedPrincipal` is an anti-corruption value object; `authorization_decision_record` is Accounting-owned immutable evidence of the policy-enforcement decision. Raw JWTs, bearer tokens, provider profile documents, names, email addresses, and other unnecessary PII are not durable accounting authorization evidence. + +The database constraints are fail-closed. Oversized or malformed normalized references write no authorization row and therefore cannot reach an accounting operation through the normal HTTP enforcement path. This is a storage-availability and provenance control, not a certification claim. + +## RED → GREEN traceability + +- Review finding: caller-derived identity references were unbounded PostgreSQL `text` columns. +- GREEN schema: all five persisted identity references require bounded CWL URN syntax; operation, permission, purpose, policy-version, and correlation evidence have explicit database ceilings. +- Regression: `tests/test_authorization_evidence_storage_contract.py` ratchets every durable text budget and the normalized reference grammar. +- Existing PostgreSQL/HTTP authorization suites continue to prove tenant isolation, append-only evidence, malformed-close authorization, and the 512-octet correlation edge. + +## Research basis + +International Telecommunication Union. (2025). *OpenID Connect Core 1.0—Errata Set 2 (Recommendation ITU-T X.1285)*. https://www.itu.int/rec/T-REC-X.1285-202505-I/en + +Sakimura, N., Bradley, J., Jones, M., de Medeiros, B., & Mortimore, C. (2014). *OpenID Connect Core 1.0 incorporating errata set 2*. OpenID Foundation. https://openid.net/specs/openid-connect-core-1_0.html + +Saint-Andre, P., & Klensin, J. (2017). *Uniform Resource Names (URNs) (RFC 8141)*. Internet Engineering Task Force. https://doi.org/10.17487/RFC8141 + +## Evidence rule + +These sources justify the identity-boundary/profile design; they do not establish accounting authority or compliance status. Merge or release requires one unchanged exact head to pass the repository and organization gates applicable to that head. From 84f4cbb686546c036befd169dc885af016ec1cad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:39:17 +0900 Subject: [PATCH 37/55] docs(auth): canonicalize identity storage references --- docs/doctoring/REFERENCES.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/REFERENCES.md b/docs/doctoring/REFERENCES.md index 6c287951..0c38ff57 100644 --- a/docs/doctoring/REFERENCES.md +++ b/docs/doctoring/REFERENCES.md @@ -32,24 +32,28 @@ IFRS Foundation. (2024). *IFRS 18 presentation and disclosure in financial state IFRS Foundation. (2024). *Post-implementation review of IFRS 15 revenue from contracts with customers: Project summary and feedback statement*. https://www.ifrs.org/projects/completed-projects/2024/pir-ifrs-15/ +International Organization for Standardization. (2022). *ISO/IEC/IEEE 42010:2022 software, systems and enterprise—Architecture description*. https://www.iso.org/standard/74393.html + International Organization for Standardization. (2026). *ISO 20022-1:2026 financial services—Universal financial industry message scheme—Part 1: Metamodel* (3rd ed.). https://www.iso.org/standard/20022-1 International Organization for Standardization. (2026). *ISO 20022-4:2026 financial services—Universal financial industry message scheme—Part 4: XML Schema generation* (2nd ed.). https://www.iso.org/standard/20022-4 International Organization for Standardization. (2026). *ISO 20022-9:2026 financial services—Universal financial industry message scheme—Part 9: Syntax generation requirements and rules* (1st ed.). https://www.iso.org/standard/20022-9 -ISO 20022 Registration Authority. (2026). *Bank-to-Customer Cash Management: camt.053.001.14 BankToCustomerStatementV14*. https://www.iso20022.org/iso-20022-message-definitions?search=camt.053 - -ISO 20022 Registration Authority. (n.d.). *Terms of use*. https://www.iso20022.org/terms-use - -ISO 20022 Registration Authority. (n.d.). *Intellectual property rights*. https://www.iso20022.org/intellectual-property-rights +International Telecommunication Union. (2025). *OpenID Connect Core 1.0—Errata Set 2* (Recommendation ITU-T X.1285). https://www.itu.int/rec/T-REC-X.1285-202505-I/en -International Organization for Standardization. (2022). *ISO/IEC/IEEE 42010:2022 software, systems and enterprise—Architecture description*. https://www.iso.org/standard/74393.html +Internet Engineering Task Force. (2017). *Uniform Resource Names (URNs)* (RFC 8141). https://doi.org/10.17487/RFC8141 Internet Engineering Task Force. (2022). *HTTP/1.1* (RFC 9112). https://www.rfc-editor.org/rfc/rfc9112 Internet Engineering Task Force. (2024). *Universally unique IDentifiers (UUIDs)* (RFC 9562). https://www.rfc-editor.org/rfc/rfc9562 +ISO 20022 Registration Authority. (2026). *Bank-to-Customer Cash Management: camt.053.001.14 BankToCustomerStatementV14*. https://www.iso20022.org/iso-20022-message-definitions?search=camt.053 + +ISO 20022 Registration Authority. (n.d.). *Terms of use*. https://www.iso20022.org/terms-use + +ISO 20022 Registration Authority. (n.d.). *Intellectual property rights*. https://www.iso20022.org/intellectual-property-rights + Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Revision 5; Release 5.2.0 current August 27, 2025). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 Korea Legislation Research Institute. (2024b). *Income Tax Act* [Unofficial translation]. https://elaw.klri.re.kr/eng_service/lawView.do?hseq=51753&lang=ENG @@ -58,6 +62,8 @@ Korea Legislation Research Institute. (2024a). *Value-Added Tax Act* [Unofficial Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information Security and Applications*. https://doi.org/10.1016/j.jisa.2025.103997 +OpenID Foundation. (2014). *OpenID Connect Core 1.0 incorporating errata set 2*. https://openid.net/specs/openid-connect-core-1_0.html + PostgreSQL Global Development Group. (2026). *PostgreSQL 18.4 release notes*. https://www.postgresql.org/docs/release/18.4/ PostgreSQL Global Development Group. (2026a). *PostgreSQL 18 documentation: Advisory locks*. https://www.postgresql.org/docs/18/functions-admin.html From 0e300adce72ec68ec1022b39253fb57608836a1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:40:21 +0900 Subject: [PATCH 38/55] fix(auth): align correlation storage units --- database/migrations/0015_authorization_decision_evidence.sql | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/database/migrations/0015_authorization_decision_evidence.sql b/database/migrations/0015_authorization_decision_evidence.sql index 96a3327b..c9ec393c 100644 --- a/database/migrations/0015_authorization_decision_evidence.sql +++ b/database/migrations/0015_authorization_decision_evidence.sql @@ -5,7 +5,8 @@ BEGIN; -- 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 --- correlation evidence ceiling is enforced again at PostgreSQL so direct SQL cannot inflate storage. +-- 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, @@ -61,7 +62,7 @@ CREATE TABLE accounting_integration.authorization_decision_record ( 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 octet_length(correlation_reference) <= 512), + 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), From ef9ffac29f26d5a92662a4f96b1ffab94981a075 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:40:42 +0900 Subject: [PATCH 39/55] test(auth): align multibyte correlation contract --- ..._authorization_evidence_storage_contract.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_authorization_evidence_storage_contract.py b/tests/test_authorization_evidence_storage_contract.py index c6c769d2..811987be 100644 --- a/tests/test_authorization_evidence_storage_contract.py +++ b/tests/test_authorization_evidence_storage_contract.py @@ -2,9 +2,12 @@ from __future__ import annotations +import json from pathlib import Path import unittest +from accounting_information_platform.http_api import _authorization_correlation + MIGRATION = Path("database/migrations/0015_authorization_decision_evidence.sql") @@ -40,7 +43,7 @@ def test_decision_vocabulary_and_correlation_are_bounded(self) -> None: self.assertIn("octet_length(permission_code) <= 129", text) self.assertIn("octet_length(purpose_code) <= 64", text) self.assertIn("octet_length(policy_version) <= 64", text) - self.assertIn("octet_length(correlation_reference) <= 512", text) + self.assertIn("char_length(correlation_reference) <= 512", text) self.assertIn("operation_code ~ '^[a-z][a-z0-9_]{1,63}$'", text) self.assertIn( "permission_code ~ '^[a-z][a-z0-9_]{1,63}\\.[a-z][a-z0-9_]{1,63}$'", @@ -48,6 +51,19 @@ def test_decision_vocabulary_and_correlation_are_bounded(self) -> None: ) self.assertIn("purpose_code ~ '^[a-z][a-z0-9_]{1,63}$'", text) + def test_multibyte_command_identity_uses_the_same_character_budget(self) -> None: + """UTF-8 command identities cannot fail storage merely because bytes exceed characters.""" + key = "한" * 160 + raw_body = json.dumps( + {"idempotency_key": key}, ensure_ascii=False + ).encode("utf-8") + + correlation = _authorization_correlation("/journal-proposals", raw_body) + + self.assertEqual(correlation, f"idempotency_key:{key}") + self.assertLessEqual(len(correlation), 512) + self.assertGreater(len(correlation.encode("utf-8")), 512) + if __name__ == "__main__": # pragma: no cover - direct local invocation only unittest.main() From b00f016d4d557f74fc4ac1e698f2a96af926b4e9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:41:07 +0900 Subject: [PATCH 40/55] docs(auth): align correlation evidence units --- .../2026-09-02-authorization-evidence-storage-bounds.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/2026-09-02-authorization-evidence-storage-bounds.md b/docs/doctoring/2026-09-02-authorization-evidence-storage-bounds.md index e43a5a84..33d19130 100644 --- a/docs/doctoring/2026-09-02-authorization-evidence-storage-bounds.md +++ b/docs/doctoring/2026-09-02-authorization-evidence-storage-bounds.md @@ -16,7 +16,7 @@ Each normalized identity reference persisted by `authorization_decision_record` RFC 8141 permits a URN namespace definition to specify its own syntax and interoperability constraints. AIS therefore constrains its internal `urn:cwl:` authorization references at the anti-corruption boundary while leaving provider-native claim representation in the identity system that owns it. -The remaining text budgets are derived from executable contracts rather than free-form estimates: operation and purpose codes follow the existing 64-octet code grammar; a permission is two such code components plus the separator (129 octets); `policy_version` is a bounded release identifier (64 octets); and `correlation_reference` mirrors the existing 512-octet HTTP evidence contract. PostgreSQL repeats these bounds so a privileged/direct SQL path cannot bypass the application checks and inflate append-only evidence. +The remaining text budgets are derived from executable contracts rather than free-form estimates: operation and purpose codes follow the existing 64-octet code grammar; a permission is two such code components plus the separator (129 octets); `policy_version` is a bounded release identifier (64 octets); and `correlation_reference` mirrors the existing 512-character HTTP evidence contract. PostgreSQL uses `char_length` for that correlation field so a multibyte command identity accepted by the HTTP contract cannot be rejected only because its UTF-8 representation uses more octets. The field remains bounded, while the ASCII-only normalized identity/vocabulary fields retain octet ceilings. PostgreSQL repeats all of these budgets so a privileged/direct SQL path cannot bypass the application contract and inflate append-only evidence. ## DDD and security boundary @@ -28,8 +28,10 @@ The database constraints are fail-closed. Oversized or malformed normalized refe - Review finding: caller-derived identity references were unbounded PostgreSQL `text` columns. - GREEN schema: all five persisted identity references require bounded CWL URN syntax; operation, permission, purpose, policy-version, and correlation evidence have explicit database ceilings. -- Regression: `tests/test_authorization_evidence_storage_contract.py` ratchets every durable text budget and the normalized reference grammar. -- Existing PostgreSQL/HTTP authorization suites continue to prove tenant isolation, append-only evidence, malformed-close authorization, and the 512-octet correlation edge. +- Follow-up review finding: the first database repair used an octet ceiling for `correlation_reference` while `_authorization_correlation()` enforced the pre-existing limit in Python characters, so a multibyte command key could be accepted by the application and then fail evidence persistence with HTTP 503. +- Follow-up GREEN: `correlation_reference` now uses the same 512-character unit at PostgreSQL; the normalized identity and vocabulary fields remain ASCII/octet-bounded. +- Regression: `tests/test_authorization_evidence_storage_contract.py` ratchets every durable text budget, the normalized reference grammar, and a multibyte idempotency key whose UTF-8 byte size exceeds 512 while its complete correlation remains within 512 characters. +- Existing PostgreSQL/HTTP authorization suites continue to prove tenant isolation, append-only evidence, malformed-close authorization, and the 512-character correlation edge. ## Research basis From dc1cb7b9dd1d03f07233f78f07cdf1beb11fceaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:21:51 +0900 Subject: [PATCH 41/55] test(auth): correct multibyte correlation boundary fixture --- tests/test_authorization_evidence_storage_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_authorization_evidence_storage_contract.py b/tests/test_authorization_evidence_storage_contract.py index 811987be..a1a54c21 100644 --- a/tests/test_authorization_evidence_storage_contract.py +++ b/tests/test_authorization_evidence_storage_contract.py @@ -53,7 +53,7 @@ def test_decision_vocabulary_and_correlation_are_bounded(self) -> None: def test_multibyte_command_identity_uses_the_same_character_budget(self) -> None: """UTF-8 command identities cannot fail storage merely because bytes exceed characters.""" - key = "한" * 160 + key = "한" * 166 raw_body = json.dumps( {"idempotency_key": key}, ensure_ascii=False ).encode("utf-8") From f0dc0288b51870ffe5d59737bd971eb384b7dc46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:37:54 +0900 Subject: [PATCH 42/55] test(auth): reserve exception-resolution permission --- ...ption_resolution_authorization_contract.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/test_reconciliation_exception_resolution_authorization_contract.py diff --git a/tests/test_reconciliation_exception_resolution_authorization_contract.py b/tests/test_reconciliation_exception_resolution_authorization_contract.py new file mode 100644 index 00000000..408c5f10 --- /dev/null +++ b/tests/test_reconciliation_exception_resolution_authorization_contract.py @@ -0,0 +1,72 @@ +"""Authorization contracts for the reconciliation-exception review command.""" + +from __future__ import annotations + +import unittest + +from accounting_information_platform.authorization import ( + AUTHORIZATION_POLICY_VERSION, + AuthenticatedPrincipal, + authorize, + permission_for_operation, + require_authorization, +) + + +_TENANT = "urn:cwl:tenant:reconciliation-exception-auth" +_OPERATION = "resolve_reconciliation_exception" +_PERMISSION = "accounting.resolve_reconciliation_exception" + + +def _principal(*permissions: str, principal_kind: str = "human") -> AuthenticatedPrincipal: + """Return one trusted-adapter principal for focused exception-review tests.""" + return AuthenticatedPrincipal( + principal_reference="urn:cwl:principal:independent_reviewer", + tenant_reference=_TENANT, + authentication_context_reference="urn:cwl:authentication:oidc-session", + granted_permission_codes=frozenset(permissions), + purpose_code="bank_reconciliation_exception_review", + credential_evidence_reference="urn:cwl:evidence:credential-session", + principal_kind=principal_kind, + ) + + +class ReconciliationExceptionResolutionAuthorizationContractTests(unittest.TestCase): + """Reserve a distinct high-impact permission before exposing resolution transport.""" + + def test_exception_resolution_has_one_explicit_versioned_permission(self) -> None: + """Exception review must not inherit completion, posting, close, or tenant authority.""" + self.assertEqual(AUTHORIZATION_POLICY_VERSION, "accounting-authorization-v3") + self.assertEqual(permission_for_operation(_OPERATION), _PERMISSION) + decision = require_authorization(_principal(_PERMISSION), _TENANT, _OPERATION) + self.assertTrue(decision.allowed) + self.assertEqual(decision.permission_code, _PERMISSION) + self.assertEqual(decision.purpose_code, "bank_reconciliation_exception_review") + self.assertEqual(decision.policy_version, AUTHORIZATION_POLICY_VERSION) + + def test_other_accounting_permissions_do_not_resolve_exception(self) -> None: + """Completion, posting, close, and read grants remain non-equivalent authorities.""" + for permission in ( + "accounting.complete_reconciliation", + "accounting.post_proposal", + "accounting.hard_close_period", + "accounting.read_close", + ): + with self.subTest(permission=permission): + decision = authorize(_principal(permission), _TENANT, _OPERATION) + self.assertFalse(decision.allowed) + self.assertEqual(decision.permission_code, _PERMISSION) + + def test_agent_origin_is_denied_resolution_even_with_copied_permission(self) -> None: + """Model/agent identity cannot promote itself into exception-review authority.""" + decision = authorize( + _principal(_PERMISSION, principal_kind="agent"), + _TENANT, + _OPERATION, + ) + self.assertFalse(decision.allowed) + self.assertEqual(decision.permission_code, _PERMISSION) + + +if __name__ == "__main__": # pragma: no cover - direct invocation convenience + unittest.main() From 764e5d0c60c6ea518a25d33a4605d1273d051231 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:38:23 +0900 Subject: [PATCH 43/55] feat(auth): reserve exception-resolution authority --- src/accounting_information_platform/authorization.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/accounting_information_platform/authorization.py b/src/accounting_information_platform/authorization.py index f0c1200b..f1ada711 100644 --- a/src/accounting_information_platform/authorization.py +++ b/src/accounting_information_platform/authorization.py @@ -18,7 +18,7 @@ from .core import _require_code, _require_reference -AUTHORIZATION_POLICY_VERSION = "accounting-authorization-v2" +AUTHORIZATION_POLICY_VERSION = "accounting-authorization-v3" _PERMISSION_PATTERN = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$") _PRINCIPAL_KINDS = frozenset(("human", "service", "agent")) @@ -55,6 +55,7 @@ "soft_close_period": "accounting.soft_close_period", "hard_close_period": "accounting.hard_close_period", "complete_reconciliation": "accounting.complete_reconciliation", + "resolve_reconciliation_exception": "accounting.resolve_reconciliation_exception", "publish_outbox": "accounting.publish_outbox", "submit_tax_artifact": "accounting.submit_tax_artifact", "manage_bank_account": "accounting.manage_bank_account", @@ -71,6 +72,7 @@ "soft_close_period", "hard_close_period", "complete_reconciliation", + "resolve_reconciliation_exception", "publish_outbox", "submit_tax_artifact", "manage_bank_account", From c7f558e1c2f7503a88794534085c2a93883bca42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:38:42 +0900 Subject: [PATCH 44/55] test(auth): advance completion policy vocabulary --- tests/test_reconciliation_completion_authorization_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_reconciliation_completion_authorization_contract.py b/tests/test_reconciliation_completion_authorization_contract.py index ca0dbd4d..aecc88d5 100644 --- a/tests/test_reconciliation_completion_authorization_contract.py +++ b/tests/test_reconciliation_completion_authorization_contract.py @@ -36,7 +36,7 @@ class ReconciliationCompletionAuthorizationContractTests(unittest.TestCase): def test_completion_operation_has_one_explicit_versioned_permission(self) -> None: """The completion command must not inherit posting, close, or tenant authority.""" - self.assertEqual(AUTHORIZATION_POLICY_VERSION, "accounting-authorization-v2") + self.assertEqual(AUTHORIZATION_POLICY_VERSION, "accounting-authorization-v3") self.assertEqual(permission_for_operation(_OPERATION), _PERMISSION) decision = require_authorization( _principal(_PERMISSION), From b9fbab41a90e4a6b1961c5cc920eb13156c35a94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:39:13 +0900 Subject: [PATCH 45/55] docs(adr): separate exception-resolution permission --- docs/adr/0055-purpose-bound-authorization.md | 70 +++++++++----------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/docs/adr/0055-purpose-bound-authorization.md b/docs/adr/0055-purpose-bound-authorization.md index ae05e9d9..0f05dd95 100644 --- a/docs/adr/0055-purpose-bound-authorization.md +++ b/docs/adr/0055-purpose-bound-authorization.md @@ -7,10 +7,10 @@ Accepted ## Context Tenant authentication identifies the accounting scope but does not establish that a caller may -read a report, post a proposal, reverse a journal, complete reconciliation review, close a period, -publish an outbox event, or submit tax evidence. PostgreSQL role and forced-RLS controls remain -necessary database defenses, but they do not replace an application decision made before a route -invokes domain work. +read a report, post a proposal, reverse a journal, resolve a reviewed reconciliation exception, +complete reconciliation review, close a period, publish an outbox event, or submit tax evidence. +PostgreSQL role and forced-RLS controls remain necessary database defenses, but they do not replace +an application decision made before a route invokes domain work. The authorization contract must also evolve as a versioned policy. Adding a new high-impact operation while continuing to emit the predecessor policy identifier would make immutable audit @@ -38,20 +38,21 @@ holds hard-close authority receives the caller-useful 400 validation response on authorization-decision evidence has been written. Invalid structure never bypasses authorization or writes journal/close/outbox facts. -Authorization policy `accounting-authorization-v2` adds the high-impact operation -`complete_reconciliation` with the distinct permission `accounting.complete_reconciliation`. -Posting, read, soft/hard-close, bank-ingest, outbox and tax permissions do not imply this permission. -An `agent` principal is denied this operation by the same default high-impact restriction even if -its untrusted context contains a copied permission string. This policy entry is deliberately -reserved before a buyer-facing reconciliation-completion transport is introduced; registering the -operation grants no route and no database capability by itself. - -The reconciliation-completion application permission remains separate from the database -`accounting_reconciliation_completer` capability that is owned by the later reconciliation -completion migration. A trusted application allow decision and a tenant-bound runtime connection -with the purpose-limited database capability are both required once the route exists. Neither -control substitutes for the other, and reconciliation completion remains separate from fiscal- -period close authority. +Authorization policy `accounting-authorization-v3` retains the high-impact operation +`complete_reconciliation` with permission `accounting.complete_reconciliation` and adds the +separate high-impact operation `resolve_reconciliation_exception` with permission +`accounting.resolve_reconciliation_exception`. Posting, read, soft/hard-close, bank-ingest, +outbox, tax, reconciliation-completion, and exception-resolution permissions are non-equivalent; +none implies another. An `agent` principal is denied both reconciliation operations by the same +default high-impact restriction even if its untrusted context contains a copied permission string. +These policy entries are deliberately reserved before their buyer-facing transports are exposed; +registering an operation grants no route and no database capability by itself. + +Reconciliation-completion and exception-resolution application permissions remain separate from +the database capabilities owned by their respective reconciliation migrations. A trusted +application allow decision and a tenant-bound runtime connection with the matching purpose-limited +database capability are both required once a route exists. Neither control substitutes for the +other, and neither reconciliation operation grants fiscal-period close or journal-posting authority. Every routed decision is appended to the tenant-scoped, forced-RLS `accounting_integration.authorization_decision_record` table. The record keeps the policy version, @@ -67,31 +68,21 @@ The standalone runner has no request-principal resolver by default and therefore ## Consequences -- Catalog readers do not implicitly receive posting, reconciliation-completion, or close authority. -- Reconciliation completion has its own permission and remains a high-impact operation denied to - model/agent principals by default. -- Extending the operation/permission registry changes the durable policy version, so audit rows can - identify which exact authorization vocabulary was evaluated. +- Catalog readers do not implicitly receive posting, reconciliation-completion, exception-resolution, or close authority. +- Reconciliation completion and exception resolution each have distinct permissions and remain high-impact operations denied to model/agent principals by default. +- Extending the operation/permission registry changes the durable policy version, so audit rows can identify which exact authorization vocabulary was evaluated. - A service or human principal can receive explicit permissions through the same host-neutral port. - Agent/model contexts are denied high-impact operations by default. -- Authorization evidence is durable and tenant isolated, while journal and command evidence keeps - its existing transaction boundaries. -- Deployment must grant the runtime login INSERT access to the authorization evidence table and - provision the host adapter before enabling accounting routes. -- The future reconciliation-completion transport must require both - `accounting.complete_reconciliation` and the separately provisioned database completion - capability; neither tenant authentication nor one of the other accounting permissions is enough. +- Authorization evidence is durable and tenant isolated, while journal and command evidence keeps its existing transaction boundaries. +- Deployment must grant the runtime login INSERT access to the authorization evidence table and provision the host adapter before enabling accounting routes. +- Future reconciliation transports must require both their exact application permission and the separately provisioned matching database capability; tenant authentication or another accounting permission is insufficient. ## Alternatives rejected -- Treating `X-CWL-Tenant-Reference` as a bearer credential would make tenant identity equal to - authority. -- Reading permission claims from request JSON or model text would let an untrusted caller promote - itself. -- Reusing `accounting.hard_close_period`, `accounting.post_proposal`, or a generic writer grant for - reconciliation completion would collapse distinct business authorities and weaken audit meaning. -- Adding the reconciliation operation without bumping `AUTHORIZATION_POLICY_VERSION` would make - immutable decision evidence unable to distinguish the predecessor and expanded policy sets. +- Treating `X-CWL-Tenant-Reference` as a bearer credential would make tenant identity equal to authority. +- Reading permission claims from request JSON or model text would let an untrusted caller promote itself. +- Reusing `accounting.hard_close_period`, `accounting.post_proposal`, `accounting.complete_reconciliation`, or a generic writer grant for exception resolution would collapse distinct business authorities and weaken audit meaning. +- Adding either reconciliation operation without bumping `AUTHORIZATION_POLICY_VERSION` would make immutable decision evidence unable to distinguish the predecessor and expanded policy sets. - Storing raw JWTs or full policy documents would add unnecessary secret and PII exposure. ## Evidence @@ -102,6 +93,9 @@ The standalone runner has no request-principal resolver by default and therefore `tests/test_reconciliation_completion_authorization_contract.py` proves that reconciliation completion has one explicit versioned permission, does not inherit posting/close/read authority, and remains denied to agent principals by default. +`tests/test_reconciliation_exception_resolution_authorization_contract.py` proves the same +independent boundary for reviewed exception resolution, including denial of completion, posting, +close, and read grants and default denial of agent principals. ## Research and standards traceability From 71db01962f60503a19a1856d0a7aa50c5205e179 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:41:16 +0900 Subject: [PATCH 46/55] docs(auth): trace exception-resolution policy v3 --- ...ception-resolution-authorization-policy.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/doctoring/2026-09-02-reconciliation-exception-resolution-authorization-policy.md diff --git a/docs/doctoring/2026-09-02-reconciliation-exception-resolution-authorization-policy.md b/docs/doctoring/2026-09-02-reconciliation-exception-resolution-authorization-policy.md new file mode 100644 index 00000000..49939f18 --- /dev/null +++ b/docs/doctoring/2026-09-02-reconciliation-exception-resolution-authorization-policy.md @@ -0,0 +1,49 @@ +# Reconciliation-exception resolution authorization policy expansion — 2026-09-02 + +## Scope + +This note records the application-authorization vocabulary required before the reviewed reconciliation-exception resolution command may receive a buyer-facing transport. It does not consume implementation bytes from the stacked exception-resolution branch, does not create a route, and does not grant PostgreSQL capability or accounting posting authority. + +## RED → GREEN sequence + +1. Added `tests/test_reconciliation_exception_resolution_authorization_contract.py` requiring a dedicated `resolve_reconciliation_exception` operation mapped to `accounting.resolve_reconciliation_exception`. +2. The RED contract proves `accounting.complete_reconciliation`, `accounting.post_proposal`, `accounting.hard_close_period`, and `accounting.read_close` are non-equivalent authorities, and an `agent` principal remains denied even when its context contains the new permission string. +3. Registered `resolve_reconciliation_exception` in `_OPERATION_PERMISSIONS` and `_HIGH_IMPACT_OPERATIONS`. +4. Bumped `AUTHORIZATION_POLICY_VERSION` from `accounting-authorization-v2` to `accounting-authorization-v3`; immutable authorization-decision evidence must not reuse a policy identifier after the operation/permission vocabulary changes. +5. Advanced the existing reconciliation-completion contract to the same v3 vocabulary so both distinct operations are evaluated under one unambiguous policy version. +6. Updated ADR 0055 to preserve the separation between application authorization, reconciliation command authority, database capability, journal posting, and fiscal-period close. + +The 2026-09-01 completion-policy note remains historical evidence for the v1 → v2 change. This note is its successor for the v2 → v3 policy expansion. + +## Authority boundary + +Exception resolution is a reviewed accounting-control decision, not reconciliation completion and not journal posting. A future transport must require all of the following independently: + +- a trusted request-scoped identity adapter has validated issuer, audience, signature, expiry, and token binding before AIS receives the principal; +- the versioned application policy allows `resolve_reconciliation_exception` through `accounting.resolve_reconciliation_exception` for the requested tenant and purpose; +- the runtime database identity possesses only the separately owned purpose-limited capability required by the named exception-resolution command; and +- the command itself satisfies its maker-checker, immutable evidence, idempotency, tenant, run, and exception invariants. + +A tenant header, a completion permission, posting or close permission, a database GUC, request-body text, Billing evidence, or model output cannot substitute for the exception-resolution permission. Agent/model principals are denied this high-impact operation by default even if an untrusted context copies the permission string. + +## DDD and product effect + +Within the Reconciliation Review bounded context, `resolve_reconciliation_exception` is the application policy name for invoking the separately owned exception-resolution command. The command's domain evidence remains authoritative for the resolution; the authorization decision is independent access-control evidence explaining why the caller was permitted or denied. Neither becomes a General Ledger journal fact or fiscal-period close command. + +## Research and standards basis + +NIST SP 800-162 models authorization from subject, target, operation, and environmental/context attributes, supporting an explicit exception-resolution operation rather than deriving authority from tenant identity. NIST SP 800-53 Rev. 5 AC-5 and AC-6 support separation of duties and least privilege, consistent with keeping exception review, reconciliation completion, posting, and close permissions distinct. Logrippo (2025) provides a current formal role/permission integrity and data-flow basis for treating permission-set changes as policy changes that require versioned evidence. + +### APA 7th references + +Hu, V. C., Ferraiolo, D., Kuhn, D. R., Schnitzer, A., Sandlin, K., Miller, R., & Scarfone, K. (2019). *Guide to attribute based access control (ABAC) definition and considerations* (NIST Special Publication 800-162, updated August 2, 2019). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-162 + +Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Revision 5, Release 5.2.0 current August 27, 2025). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + +Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information Security and Applications*. https://doi.org/10.1016/j.jisa.2025.103997 + +These sources inform control design only. They do not grant accounting authority, establish SOC 2/CSAP certification, or replace exact-head PostgreSQL, authorization, security, review, and deployment evidence. + +## Integration boundary + +This authorization sibling remains blocked behind the reconciliation dependency root and must be restacked/revalidated against the exact protected integrated base. The exception-resolution transport itself remains later work: reserving the operation/permission vocabulary is intentionally independent of consuming the current stacked command implementation. No predecessor check, review, or release evidence transfers across the eventual restack. From 27ca1800a2d2ea9e1b53d7941559c462d76ce05f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:38:18 +0900 Subject: [PATCH 47/55] test: reserve unique authorization ADR identity --- tests/test_authorization_adr_governance.py | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/test_authorization_adr_governance.py diff --git a/tests/test_authorization_adr_governance.py b/tests/test_authorization_adr_governance.py new file mode 100644 index 00000000..a120a9a1 --- /dev/null +++ b/tests/test_authorization_adr_governance.py @@ -0,0 +1,30 @@ +"""Governance contracts for the purpose-bound authorization decision record.""" + +from __future__ import annotations + +from pathlib import Path +import unittest + + +ADR = Path("docs/adr/0064-purpose-bound-authorization.md") +COLLIDING_PREDECESSOR = Path("docs/adr/0055-purpose-bound-authorization.md") + + +class AuthorizationAdrGovernanceTests(unittest.TestCase): + """Keep the authorization ADR uniquely numbered and unaccepted before integration.""" + + def test_authorization_adr_has_unique_number_and_proposed_status(self) -> None: + """Concurrent ADR numbering and Draft evidence cannot create a false Accepted decision.""" + self.assertTrue(ADR.is_file(), "purpose-bound authorization must own ADR 0064") + self.assertFalse( + COLLIDING_PREDECESSOR.exists(), + "ADR 0055 is owned by the reconciliation dependency root", + ) + text = ADR.read_text(encoding="utf-8") + self.assertTrue(text.startswith("# ADR 0064: Purpose-bound application authorization")) + self.assertIn("## Status\n\nProposed", text) + self.assertNotIn("## Status\n\nAccepted", text) + + +if __name__ == "__main__": # pragma: no cover - direct invocation convenience + unittest.main() From ad0b900596799ecf969ea94bf6858dd782b77378 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:38:56 +0900 Subject: [PATCH 48/55] docs: assign authorization ADR 0064 proposed --- docs/adr/0064-purpose-bound-authorization.md | 129 +++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/adr/0064-purpose-bound-authorization.md diff --git a/docs/adr/0064-purpose-bound-authorization.md b/docs/adr/0064-purpose-bound-authorization.md new file mode 100644 index 00000000..764cbede --- /dev/null +++ b/docs/adr/0064-purpose-bound-authorization.md @@ -0,0 +1,129 @@ +# ADR 0064: Purpose-bound application authorization + +## Status + +Proposed + +## Context + +Tenant authentication identifies the accounting scope but does not establish that a caller may +read a report, post a proposal, reverse a journal, resolve a reviewed reconciliation exception, +complete reconciliation review, close a period, publish an outbox event, or submit tax evidence. +PostgreSQL role and forced-RLS controls remain necessary database defenses, but they do not replace +an application decision made before a route invokes domain work. + +The authorization contract must also evolve as a versioned policy. Adding a new high-impact +operation while continuing to emit the predecessor policy identifier would make immutable audit +evidence ambiguous: two different operation/permission sets would both claim the same policy +version. + +This decision is Proposed while the implementation PR is unintegrated and dependency-bound. The +original draft used ADR number 0055 concurrently with reconciliation and readiness decisions. ADR +identity is repository-wide governance evidence, so this authorization decision owns 0064 instead +and cannot become Accepted until the exact integrated protected head satisfies its required gates. + +## Decision + +The trusted host identity adapter supplies an immutable `AuthenticatedPrincipal` containing only +validated opaque principal, tenant, authentication-context, purpose, permission, and credential- +evidence references plus an explicit `principal_kind`. `principal_kind` must be one of `human`, +`service`, or `agent`; it has no implicit default, so an adapter omission fails before route +authorization. It does not pass bearer tokens, policy documents, or model output into AIS. + +The HTTP boundary maps every accounting route to a stable operation code before invoking `accept` +or `lookup`. A missing, unknown, tenant-mismatched, agent-originated high-impact, or insufficient +permission decision fails closed with HTTP 403. Soft-close and hard-close have independent +permissions. Request-body fields, tenant headers, database GUCs, Billing documents, and model +text cannot grant authority. + +Malformed or non-object `/period-closes` bodies are conservatively classified as +`hard_close_period` for the authorization step instead of returning `None`. Therefore an +unauthorized caller receives 403 without reaching the close handler, while a caller that actually +holds hard-close authority receives the caller-useful 400 validation response only after durable +authorization-decision evidence has been written. Invalid structure never bypasses authorization +or writes journal/close/outbox facts. + +Authorization policy `accounting-authorization-v3` retains the high-impact operation +`complete_reconciliation` with permission `accounting.complete_reconciliation` and adds the +separate high-impact operation `resolve_reconciliation_exception` with permission +`accounting.resolve_reconciliation_exception`. Posting, read, soft/hard-close, bank-ingest, +outbox, tax, reconciliation-completion, and exception-resolution permissions are non-equivalent; +none implies another. An `agent` principal is denied both reconciliation operations by the same +default high-impact restriction even if its untrusted context contains a copied permission string. +These policy entries are deliberately reserved before their buyer-facing transports are exposed; +registering an operation grants no route and no database capability by itself. + +Reconciliation-completion and exception-resolution application permissions remain separate from +the database capabilities owned by their respective reconciliation migrations. A trusted +application allow decision and a tenant-bound runtime connection with the matching purpose-limited +database capability are both required once a route exists. Neither control substitutes for the +other, and neither reconciliation operation grants fiscal-period close or journal-posting authority. + +Every routed decision is appended to the tenant-scoped, forced-RLS +`accounting_integration.authorization_decision_record` table. The record keeps the policy version, +decision, principal/purpose evidence, principal tenant, requested tenant, operation, required +permission, and bounded correlation identity. It never stores raw credentials. Database mutation +triggers make the evidence append-only, and the persistence boundary rejects evidence whose +requested tenant differs from the tenant scope used to store it. The persistence boundary also +accepts only unchanged decisions issued by the `authorize` evaluator, so a caller cannot +construct or mutate an `allowed` decision and promote it into durable evidence; copying an +evaluator decision retains its provenance. + +The standalone runner has no request-principal resolver by default and therefore exposes only health status. A trusted host adapter integrates through `request_principal_resolver`; the resolver is invoked for each request and must return only that request's validated `AuthenticatedPrincipal`. The server never accepts one reusable authenticated principal as authority for every connected client. + +## Consequences + +- Catalog readers do not implicitly receive posting, reconciliation-completion, exception-resolution, or close authority. +- Reconciliation completion and exception resolution each have distinct permissions and remain high-impact operations denied to model/agent principals by default. +- Extending the operation/permission registry changes the durable policy version, so audit rows can identify which exact authorization vocabulary was evaluated. +- A service or human principal can receive explicit permissions through the same host-neutral port. +- Agent/model contexts are denied high-impact operations by default. +- Authorization evidence is durable and tenant isolated, while journal and command evidence keeps its existing transaction boundaries. +- Deployment must grant the runtime login INSERT access to the authorization evidence table and provision the host adapter before enabling accounting routes. +- Future reconciliation transports must require both their exact application permission and the separately provisioned matching database capability; tenant authentication or another accounting permission is insufficient. + +## Alternatives rejected + +- Treating `X-CWL-Tenant-Reference` as a bearer credential would make tenant identity equal to authority. +- Reading permission claims from request JSON or model text would let an untrusted caller promote itself. +- Reusing `accounting.hard_close_period`, `accounting.post_proposal`, `accounting.complete_reconciliation`, or a generic writer grant for exception resolution would collapse distinct business authorities and weaken audit meaning. +- Adding either reconciliation operation without bumping `AUTHORIZATION_POLICY_VERSION` would make immutable decision evidence unable to distinguish the predecessor and expanded policy sets. +- Storing raw JWTs or full policy documents would add unnecessary secret and PII exposure. +- Reusing ADR 0055 for a concurrent authorization decision was rejected because the reconciliation dependency root already owns that identity and ambiguous ADR numbers make later protected-tree traceability non-deterministic. + +## Evidence + +`src/accounting_information_platform/authorization.py` owns the immutable decision contract and +`http_api.py` performs route mapping before domain dispatch. Migration +`0015_authorization_decision_evidence.sql` owns tenant isolation and append-only audit evidence. +`tests/test_reconciliation_completion_authorization_contract.py` proves that reconciliation +completion has one explicit versioned permission, does not inherit posting/close/read authority, +and remains denied to agent principals by default. +`tests/test_reconciliation_exception_resolution_authorization_contract.py` proves the same +independent boundary for reviewed exception resolution, including denial of completion, posting, +close, and read grants and default denial of agent principals. +`tests/test_authorization_adr_governance.py` ratchets the unique ADR identity and Proposed status +until protected integration evidence exists. + +## Research and standards traceability + +Hu, V. C., Ferraiolo, D., Kuhn, D. R., Schnitzer, A., Sandlin, K., Miller, R., & Scarfone, K. +(2019). *Guide to attribute based access control (ABAC) definition and considerations* (NIST +Special Publication 800-162, updated August 2, 2019). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-162 + +Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* +(NIST Special Publication 800-53 Revision 5, Release 5.2.0 current as of August 27, 2025). +National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 + +Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information +Security and Applications*. +https://doi.org/10.1016/j.jisa.2025.103997 + +NIST SP 800-162 defines authorization in terms of subject/object/operation/environment attributes, +which supports keeping tenant identity, requested operation, purpose and principal kind as distinct +decision inputs. NIST SP 800-53 Rev. 5 AC-6 supports least privilege and purpose-limited roles and +process privileges. Logrippo (2025) provides a current formal RBAC integrity/data-flow basis for +reasoning about role/permission assignments and reconfiguration. These sources inform control +design only; they do not grant accounting authority or replace exact-head tests and deployment +evidence. From ef58aeaee696ab3db6401575719be8ca5b4ad989 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:39:02 +0900 Subject: [PATCH 49/55] docs: retire colliding authorization ADR 0055 --- docs/adr/0055-purpose-bound-authorization.md | 121 ------------------- 1 file changed, 121 deletions(-) delete mode 100644 docs/adr/0055-purpose-bound-authorization.md diff --git a/docs/adr/0055-purpose-bound-authorization.md b/docs/adr/0055-purpose-bound-authorization.md deleted file mode 100644 index 0f05dd95..00000000 --- a/docs/adr/0055-purpose-bound-authorization.md +++ /dev/null @@ -1,121 +0,0 @@ -# ADR 0055: Purpose-bound application authorization - -## Status - -Accepted - -## Context - -Tenant authentication identifies the accounting scope but does not establish that a caller may -read a report, post a proposal, reverse a journal, resolve a reviewed reconciliation exception, -complete reconciliation review, close a period, publish an outbox event, or submit tax evidence. -PostgreSQL role and forced-RLS controls remain necessary database defenses, but they do not replace -an application decision made before a route invokes domain work. - -The authorization contract must also evolve as a versioned policy. Adding a new high-impact -operation while continuing to emit the predecessor policy identifier would make immutable audit -evidence ambiguous: two different operation/permission sets would both claim the same policy -version. - -## Decision - -The trusted host identity adapter supplies an immutable `AuthenticatedPrincipal` containing only -validated opaque principal, tenant, authentication-context, purpose, permission, and credential- -evidence references plus an explicit `principal_kind`. `principal_kind` must be one of `human`, -`service`, or `agent`; it has no implicit default, so an adapter omission fails before route -authorization. It does not pass bearer tokens, policy documents, or model output into AIS. - -The HTTP boundary maps every accounting route to a stable operation code before invoking `accept` -or `lookup`. A missing, unknown, tenant-mismatched, agent-originated high-impact, or insufficient -permission decision fails closed with HTTP 403. Soft-close and hard-close have independent -permissions. Request-body fields, tenant headers, database GUCs, Billing documents, and model -text cannot grant authority. - -Malformed or non-object `/period-closes` bodies are conservatively classified as -`hard_close_period` for the authorization step instead of returning `None`. Therefore an -unauthorized caller receives 403 without reaching the close handler, while a caller that actually -holds hard-close authority receives the caller-useful 400 validation response only after durable -authorization-decision evidence has been written. Invalid structure never bypasses authorization -or writes journal/close/outbox facts. - -Authorization policy `accounting-authorization-v3` retains the high-impact operation -`complete_reconciliation` with permission `accounting.complete_reconciliation` and adds the -separate high-impact operation `resolve_reconciliation_exception` with permission -`accounting.resolve_reconciliation_exception`. Posting, read, soft/hard-close, bank-ingest, -outbox, tax, reconciliation-completion, and exception-resolution permissions are non-equivalent; -none implies another. An `agent` principal is denied both reconciliation operations by the same -default high-impact restriction even if its untrusted context contains a copied permission string. -These policy entries are deliberately reserved before their buyer-facing transports are exposed; -registering an operation grants no route and no database capability by itself. - -Reconciliation-completion and exception-resolution application permissions remain separate from -the database capabilities owned by their respective reconciliation migrations. A trusted -application allow decision and a tenant-bound runtime connection with the matching purpose-limited -database capability are both required once a route exists. Neither control substitutes for the -other, and neither reconciliation operation grants fiscal-period close or journal-posting authority. - -Every routed decision is appended to the tenant-scoped, forced-RLS -`accounting_integration.authorization_decision_record` table. The record keeps the policy version, -decision, principal/purpose evidence, principal tenant, requested tenant, operation, required -permission, and bounded correlation identity. It never stores raw credentials. Database mutation -triggers make the evidence append-only, and the persistence boundary rejects evidence whose -requested tenant differs from the tenant scope used to store it. The persistence boundary also -accepts only unchanged decisions issued by the `authorize` evaluator, so a caller cannot -construct or mutate an `allowed` decision and promote it into durable evidence; copying an -evaluator decision retains its provenance. - -The standalone runner has no request-principal resolver by default and therefore exposes only health status. A trusted host adapter integrates through `request_principal_resolver`; the resolver is invoked for each request and must return only that request's validated `AuthenticatedPrincipal`. The server never accepts one reusable authenticated principal as authority for every connected client. - -## Consequences - -- Catalog readers do not implicitly receive posting, reconciliation-completion, exception-resolution, or close authority. -- Reconciliation completion and exception resolution each have distinct permissions and remain high-impact operations denied to model/agent principals by default. -- Extending the operation/permission registry changes the durable policy version, so audit rows can identify which exact authorization vocabulary was evaluated. -- A service or human principal can receive explicit permissions through the same host-neutral port. -- Agent/model contexts are denied high-impact operations by default. -- Authorization evidence is durable and tenant isolated, while journal and command evidence keeps its existing transaction boundaries. -- Deployment must grant the runtime login INSERT access to the authorization evidence table and provision the host adapter before enabling accounting routes. -- Future reconciliation transports must require both their exact application permission and the separately provisioned matching database capability; tenant authentication or another accounting permission is insufficient. - -## Alternatives rejected - -- Treating `X-CWL-Tenant-Reference` as a bearer credential would make tenant identity equal to authority. -- Reading permission claims from request JSON or model text would let an untrusted caller promote itself. -- Reusing `accounting.hard_close_period`, `accounting.post_proposal`, `accounting.complete_reconciliation`, or a generic writer grant for exception resolution would collapse distinct business authorities and weaken audit meaning. -- Adding either reconciliation operation without bumping `AUTHORIZATION_POLICY_VERSION` would make immutable decision evidence unable to distinguish the predecessor and expanded policy sets. -- Storing raw JWTs or full policy documents would add unnecessary secret and PII exposure. - -## Evidence - -`src/accounting_information_platform/authorization.py` owns the immutable decision contract and -`http_api.py` performs route mapping before domain dispatch. Migration -`0015_authorization_decision_evidence.sql` owns tenant isolation and append-only audit evidence. -`tests/test_reconciliation_completion_authorization_contract.py` proves that reconciliation -completion has one explicit versioned permission, does not inherit posting/close/read authority, -and remains denied to agent principals by default. -`tests/test_reconciliation_exception_resolution_authorization_contract.py` proves the same -independent boundary for reviewed exception resolution, including denial of completion, posting, -close, and read grants and default denial of agent principals. - -## Research and standards traceability - -Hu, V. C., Ferraiolo, D., Kuhn, D. R., Schnitzer, A., Sandlin, K., Miller, R., & Scarfone, K. -(2019). *Guide to attribute based access control (ABAC) definition and considerations* (NIST -Special Publication 800-162, updated August 2, 2019). National Institute of Standards and -Technology. https://doi.org/10.6028/NIST.SP.800-162 - -Joint Task Force. (2020). *Security and privacy controls for information systems and organizations* -(NIST Special Publication 800-53 Revision 5, Release 5.2.0 current as of August 27, 2025). -National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-53r5 - -Logrippo, L. (2025). Data flow security in role-based access control. *Journal of Information -Security and Applications*. -https://doi.org/10.1016/j.jisa.2025.103997 - -NIST SP 800-162 defines authorization in terms of subject/object/operation/environment attributes, -which supports keeping tenant identity, requested operation, purpose and principal kind as distinct -decision inputs. NIST SP 800-53 Rev. 5 AC-6 supports least privilege and purpose-limited roles and -process privileges. Logrippo (2025) provides a current formal RBAC integrity/data-flow basis for -reasoning about role/permission assignments and reconfiguration. These sources inform control -design only; they do not grant accounting authority or replace exact-head tests and deployment -evidence. From 42e6c7db1a17115b53e35039c7968d48d90e980b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:41:11 +0900 Subject: [PATCH 50/55] fix: point repository contract at ADR 0064 --- scripts/validate_repository.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/validate_repository.py b/scripts/validate_repository.py index 97958e04..8caa3568 100644 --- a/scripts/validate_repository.py +++ b/scripts/validate_repository.py @@ -110,7 +110,7 @@ "docs/adr/0050-postgresql-concurrency-hot-partition.md", "docs/adr/0051-accounting-book-period-control.md", "docs/adr/0052-bank-statement-evidence-registry.md", - "docs/adr/0055-purpose-bound-authorization.md", + "docs/adr/0064-purpose-bound-authorization.md", "docs/doctoring/REFERENCES.md", "docs/doctoring/STANDARD_TRACEABILITY.md", "docs/superpowers/specs/2026-08-16-accounting-information-platform-design.md", From d6674b878b5337663ffb3c16e39b59a990a654ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:41:31 +0900 Subject: [PATCH 51/55] docs: point completion trace to ADR 0064 --- ...2026-09-01-reconciliation-completion-authorization-policy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md b/docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md index ec1cd42f..b99e6f7a 100644 --- a/docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md +++ b/docs/doctoring/2026-09-01-reconciliation-completion-authorization-policy.md @@ -11,7 +11,7 @@ This note records the application-authorization work needed before a buyer-facin 3. Registered `complete_reconciliation` in `_OPERATION_PERMISSIONS` and `_HIGH_IMPACT_OPERATIONS`. 4. Strengthened the contract to require a new durable authorization policy identifier because the operation/permission vocabulary changed. 5. Bumped `AUTHORIZATION_POLICY_VERSION` to `accounting-authorization-v2` so immutable authorization-decision evidence does not claim the predecessor policy version for an expanded policy set. -6. Corrected ADR 0055's stale malformed-period-close prose: current source conservatively classifies malformed/non-object `/period-closes` bodies as `hard_close_period`, records authorization evidence, then returns either 403 before domain work or the caller-useful 400 validation response for a genuinely hard-close-authorized principal. Invalid structure cannot bypass authorization. +6. Corrected ADR 0064's stale malformed-period-close prose: current source conservatively classifies malformed/non-object `/period-closes` bodies as `hard_close_period`, records authorization evidence, then returns either 403 before domain work or the caller-useful 400 validation response for a genuinely hard-close-authorized principal. Invalid structure cannot bypass authorization. ## Authority boundary From 04488f52a09bd31b67135a6eb0c3a0fcd1fa074c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:41:56 +0900 Subject: [PATCH 52/55] docs: point request-scope trace to ADR 0064 --- docs/doctoring/2026-09-01-request-scoped-principal-authority.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/2026-09-01-request-scoped-principal-authority.md b/docs/doctoring/2026-09-01-request-scoped-principal-authority.md index b1d4438d..46a71254 100644 --- a/docs/doctoring/2026-09-01-request-scoped-principal-authority.md +++ b/docs/doctoring/2026-09-01-request-scoped-principal-authority.md @@ -24,7 +24,7 @@ If the trusted identity adapter is unavailable or raises unexpectedly, the route | Independent requests on one server obtain independent principals and permissions | `tests/test_request_scoped_authorization_context.py::test_each_request_resolves_its_own_validated_principal` | | Identity-adapter outage cannot fall back to cached/shared authority | `tests/test_request_scoped_authorization_context.py::test_identity_adapter_failure_is_fail_closed_before_audit_allow` | | Existing purpose/permission and audit behavior remains intact | `tests/test_authorization.py` plus the real PostgreSQL authorization tests exercised by Accounting Foundation CI | -| Repository documentation exposes the same boundary | `docs/ARCHITECTURE.md`, `docs/SECURITY.md`, `docs/OPERABILITY.md`, ADR 0055, and `CHANGELOG.md` | +| Repository documentation exposes the same boundary | `docs/ARCHITECTURE.md`, `docs/SECURITY.md`, `docs/OPERABILITY.md`, ADR 0064, and `CHANGELOG.md` | Focused exact-source verification during the bounded repair passed 25 authorization tests, repository validation, and Python compilation with `PYTHONPATH=src:.`. The normalized connector commit that adds this doctoring record is intentionally separate so ordinary synchronize-triggered exact-head workflows can validate the final source after the temporary repair workflow self-deleted. From 1afd776d23af0f579002eb5fdf8aa4eec20ffb7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 16:42:14 +0900 Subject: [PATCH 53/55] docs: point exception policy trace to ADR 0064 --- ...-reconciliation-exception-resolution-authorization-policy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/2026-09-02-reconciliation-exception-resolution-authorization-policy.md b/docs/doctoring/2026-09-02-reconciliation-exception-resolution-authorization-policy.md index 49939f18..f8dc779a 100644 --- a/docs/doctoring/2026-09-02-reconciliation-exception-resolution-authorization-policy.md +++ b/docs/doctoring/2026-09-02-reconciliation-exception-resolution-authorization-policy.md @@ -11,7 +11,7 @@ This note records the application-authorization vocabulary required before the r 3. Registered `resolve_reconciliation_exception` in `_OPERATION_PERMISSIONS` and `_HIGH_IMPACT_OPERATIONS`. 4. Bumped `AUTHORIZATION_POLICY_VERSION` from `accounting-authorization-v2` to `accounting-authorization-v3`; immutable authorization-decision evidence must not reuse a policy identifier after the operation/permission vocabulary changes. 5. Advanced the existing reconciliation-completion contract to the same v3 vocabulary so both distinct operations are evaluated under one unambiguous policy version. -6. Updated ADR 0055 to preserve the separation between application authorization, reconciliation command authority, database capability, journal posting, and fiscal-period close. +6. Updated ADR 0064 to preserve the separation between application authorization, reconciliation command authority, database capability, journal posting, and fiscal-period close. The 2026-09-01 completion-policy note remains historical evidence for the v1 → v2 change. This note is its successor for the v2 → v3 policy expansion. From 4ef1f4226ea3d432048215fa2fe11c976cda4b05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:31:35 +0900 Subject: [PATCH 54/55] test: bind authorization traceability to ADR 0064 --- tests/test_authorization_adr_governance.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_authorization_adr_governance.py b/tests/test_authorization_adr_governance.py index a120a9a1..d30d7e46 100644 --- a/tests/test_authorization_adr_governance.py +++ b/tests/test_authorization_adr_governance.py @@ -8,6 +8,7 @@ ADR = Path("docs/adr/0064-purpose-bound-authorization.md") COLLIDING_PREDECESSOR = Path("docs/adr/0055-purpose-bound-authorization.md") +STANDARD_TRACEABILITY = Path("docs/doctoring/STANDARD_TRACEABILITY.md") class AuthorizationAdrGovernanceTests(unittest.TestCase): @@ -25,6 +26,17 @@ def test_authorization_adr_has_unique_number_and_proposed_status(self) -> None: self.assertIn("## Status\n\nProposed", text) self.assertNotIn("## Status\n\nAccepted", text) + def test_authorization_traceability_points_to_current_adr(self) -> None: + """Canonical standards traceability must not retain the retired authorization ADR number.""" + text = STANDARD_TRACEABILITY.read_text(encoding="utf-8") + row = next( + line + for line in text.splitlines() + if line.startswith("| CWL purpose-bound authorization contract |") + ) + self.assertIn("ADR 0064", row) + self.assertNotIn("ADR 0055", row) + if __name__ == "__main__": # pragma: no cover - direct invocation convenience unittest.main() From 9cdafb76c0a727943692a1217872618d9251314b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 18:32:33 +0900 Subject: [PATCH 55/55] docs: point authorization traceability to ADR 0064 --- docs/doctoring/STANDARD_TRACEABILITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/STANDARD_TRACEABILITY.md b/docs/doctoring/STANDARD_TRACEABILITY.md index ff9a1b87..10dc981a 100644 --- a/docs/doctoring/STANDARD_TRACEABILITY.md +++ b/docs/doctoring/STANDARD_TRACEABILITY.md @@ -9,7 +9,7 @@ | IAS 10 | Soft-close rejects ordinary posts and allows AIS-owned adjusting journals plus append-only reversing adjustments before hard-close snapshots and locks the period. Hard-close loads the close package in one consistent read, parks period earnings on 310100, and stores the close `idempotency_key` on the snapshot so a later journal or a different close key fails closed. Auditors later read those durable hard-close receipts from stored snapshots, not reconstructed soft-close history. Controllers also read the unadjusted, adjusted, and post-close trial-balance worksheet on the existing TB GET, and list the adjusting worksheet population on the existing journal list | Two-step `POST /period-closes`, HTTP `POST /journals` adjusting write, HTTP period-close list, HTTP trial-balance basis, HTTP journal-source list, ADR 0023, ADR 0024, ADR 0030, ADR 0031, ADR 0036, and ADR 0038 | | IFRS 9 | Receivable aging is control evidence of credit risk at the legal-entity book, not a customer subledger and not an expected-credit-loss allowance. AIS ages posted AR FIFO through period end and does not invent `party_reference`. A Billing issued-invoice void credits AR through ordinary ingest on `{tenant}:issued_invoice_void:{issued_invoice_void_id}:{void.source_payload_hash}:v{issued_invoice_void_contract_version}` and drops entity receivable aging by that inclusive amount; it is not a collection-status command and does not require Billing `reversed_journal_proposal_id` or `invoice_draft_id`. A Billing issued-credit-note void debits AR through ordinary ingest on `{tenant}:issued_credit_note_void:{issued_credit_note_void_id}:{void.source_payload_hash}:v{issued_credit_note_void_contract_version}` and raises entity receivable aging by the inclusive amount the credit had reduced; it does not require Billing `journal_entry_id`. A Billing collection write-off of a financial asset posts debit `write_off_expense` 510100 / credit AR through ordinary ingest, reduces entity receivable aging by that amount, and parks that expense into retained earnings on hard-close; it is not an ECL allowance and is not `period_closing` | HTTP receivable aging, HTTP issued-invoice-void consume, HTTP collection write-off catalog, ADR 0039, and ADR 0042 | | IFRS 18 | Financial-statement presentation is a versioned projection separate from the journal core | Reporting boundary and roadmap | -| ISO 20022-1:2026 / ISO 20022-4:2026 / ISO 20022-9:2026 / RA camt.053.001.14 | Bank-statement adapter pins `BankToCustomerStatementV14`, vendors SHA-256 adapter evidence, rejects other revisions, stores hashes/locators rather than raw XML, records `TxDtls/AmtDtls/TxAmt/Amt` only, and fail-closes when the statement account-identifier hash does not match the registered bank account. Deterministic bank-to-book matching consumes that normalized evidence but treats matching precedence as an AIS control rather than an ISO-prescribed algorithm: present provider/end-to-end/account-servicer identities outrank the weaker exact-money/date rule; amount, currency, and CRDT/DBIT economic direction must agree; direction conflicts fail closed as `direction_mismatch`; the reconciliation evidence boundary accepts only finite, strictly positive `Decimal` amounts, rejecting binary floats, zero, negative, `NaN`, and infinite values before candidate comparison; `date_window_days` must be a non-negative integer (boolean, fractional, and negative values fail at policy construction); ambiguous populations abstain and a proposal never posts a journal. Reconciliation result structure is also fail-closed evidence: a `match` carries exactly one journal, a finite strictly positive exact `Decimal` allocation, and no exception code; an `abstain` carries no matched journal, exact zero `Decimal` allocation, and a non-empty exception code, so direct callers cannot forge success-shaped close-review input. CRDT/DBIT remains separate direction evidence rather than a signed-amount convention. The exact book-to-bank bridge is likewise an AIS close control rather than an ISO rule: it independently proves statement opening + movements = closing, posted-book opening + movements = closing, and reconciled book + outstanding book - outstanding bank = statement closing with exact `Decimal` values, retains run/statement/book population provenance, never tolerance-rounds a difference, and cannot post, reverse, or approve a journal. When a bridge enters period-close review it also carries immutable tenant/legal-entity/accounting-book/bank-account-assignment identity. The buyer close-review projection is a read-only AIS presentation over those controls: it requires its supplied scope to equal the bridge-bound scope, rejects unbound or relabelled same-currency bridges, carries that scope plus run/population provenance, exact bank/book/reconciled/outstanding/unexplained values, unresolved statement-entry references and preceding-run deltas; eligibility requires exactly one decision for every expected immutable statement entry, so missing, duplicate, or extraneous decisions fail closed; preceding-run deltas require both current and preceding bridges to be bound to the same immutable scope rather than currency equality or caller assertions alone; JSON/CSV preserve monetary values as decimal strings; `suitable_for_period_close_review` is evidence eligibility only and never reconciliation approval, period-close authority, or journal-posting permission. Split and aggregate allocation proposals are exact-`Decimal` conservation evidence: a split sums exactly to the statement amount, an aggregate conserves the exact journal-side total on both sides, and every `ReconciliationAllocation` is immutable, tenant- and run-scoped with no double consumption. Persisted `reconciliation_candidate` / `reconciliation_match` / `statement_match_allocation` / `journal_match_allocation` rows are forced-RLS tenant-scoped and a partial unique index enforces at most one \`approved\` match per run so a consumed source amount cannot be reused; allocation planning and persistence still never post, reverse, approve, or adjust a journal | Immutable bank-statement evidence registry, deterministic reconciliation proposal engine, exact book-to-bank bridge projection, close-review projection and exact-value export regressions, population/scope and bridge-scope regressions, decision-structure regressions, direction, monetary-domain, policy, and bridge regressions, ADR 0052, ADR 0054 | +| ISO 20022-1:2026 / ISO 20022-4:2026 / ISO 20022-9:2026 / RA camt.053.001.14 | Bank-statement adapter pins `BankToCustomerStatementV14`, vendors SHA-256 adapter evidence, rejects other revisions, stores hashes/locators rather than raw XML, records `TxDtls/AmtDtls/TxAmt/Amt` only, and fail-closes when the statement account-identifier hash does not match the registered bank account. Deterministic bank-to-book matching consumes that normalized evidence but treats matching precedence as an AIS control rather than an ISO-prescribed algorithm: present provider/end-to-end/account-servicer identities outrank the weaker exact-money/date rule; amount, currency, and CRDT/DBIT economic direction must agree; direction conflicts fail closed as `direction_mismatch`; the reconciliation evidence boundary accepts only finite, strictly positive `Decimal` amounts, rejecting binary floats, zero, negative, `NaN`, and infinite values before candidate comparison; `date_window_days` must be a non-negative integer (boolean, fractional, and negative values fail at policy construction); ambiguous populations abstain and a proposal never posts a journal. Reconciliation result structure is also fail-closed evidence: a `match` carries exactly one journal, a finite strictly positive exact `Decimal` allocation, and no exception code; an `abstain` carries no matched journal, exact zero `Decimal` allocation, and a non-empty exception code, so direct callers cannot forge success-shaped close-review input. CRDT/DBIT remains separate direction evidence rather than a signed-amount convention. The exact book-to-bank bridge is likewise an AIS close control rather than an ISO rule: it independently proves statement opening + movements = closing, posted-book opening + movements = closing, and reconciled book + outstanding book - outstanding bank = statement closing with exact `Decimal` values, retains run/statement/book population provenance, never tolerance-rounds a difference, and cannot post, reverse, or approve a journal. When a bridge enters period-close review it also carries immutable tenant/legal-entity/accounting-book/bank-account-assignment identity. The buyer close-review projection is a read-only AIS presentation over those controls: it requires its supplied scope to equal the bridge-bound scope, rejects unbound or relabelled same-currency bridges, carries that scope plus run/population provenance, exact bank/book/reconciled/outstanding/unexplained values, unresolved statement-entry references and preceding-run deltas; eligibility requires exactly one decision for every expected immutable statement entry, so missing, duplicate, or extraneous decisions fail closed; preceding-run deltas require both current and preceding bridges to be bound to the same immutable scope rather than currency equality or caller assertions alone; JSON/CSV preserve monetary values as decimal strings; `suitable_for_period_close_review` is evidence eligibility only and never reconciliation approval, period-close authority, or journal-posting permission. Split and aggregate allocation proposals are exact-`Decimal` conservation evidence: a split sums exactly to the statement amount, an aggregate conserves the exact journal-side total on both sides, and every `ReconciliationAllocation` is immutable, tenant- and run-scoped with no double consumption. Persisted `reconciliation_candidate` / `reconciliation_match` / `statement_match_allocation` / `journal_match_allocation` rows are forced-RLS tenant-scoped and a partial unique index enforces at most one `approved` match per run so a consumed source amount cannot be reused; allocation planning and persistence still never post, reverse, approve, or adjust a journal | Immutable bank-statement evidence registry, deterministic reconciliation proposal engine, exact book-to-bank bridge projection, close-review projection and exact-value export regressions, population/scope and bridge-scope regressions, decision-structure regressions, direction, monetary-domain, policy, and bridge regressions, ADR 0052, ADR 0054 | | PostgreSQL 18.4 | Use current supported minor release, UUIDv7, exact numeric types, composite foreign keys, forced row-level security, database-controlled `session_user` → tenant runtime binding, transaction-level advisory locks, bounded lock waits, shared fiscal-period command locks, close row locks, tenant-leading high-write indexes, and a partition migration contract that preserves partition-key identity. The journal header binds tenant + legal entity + accounting book through a composite foreign key so independently valid identifiers cannot cross legal-entity scope. The normalized journal line keeps no redundant book column; a database trigger instead rejects any chart account whose accounting book differs from the parent journal. Ordinary runtime credentials cannot select or mutate the binding table and caller-controlled GUCs are not tenant authority | Initial migration, book-scope PostgreSQL regressions, data-model contract, runtime-tenant binding migration, real restricted-runtime RLS tests, ADR 0049, ADR 0050 | | RFC 9112 | The standalone HTTP/1.1 command boundary deliberately does not implement transfer coding: any request carrying `Transfer-Encoding` fails closed with HTTP 400 and connection close rather than being combined with a `Content-Length` interpretation. A valid `Content-Length` is an exact octet contract; premature EOF/short reads are incomplete messages, fail with HTTP 400, and close the connection before JSON/domain processing. This prevents ambiguous message boundaries from becoming request-smuggling or valid-prefix acceptance paths | `JournalProposalHandler._read_body`, HTTP request-boundary RED/GREEN regressions, RFC 9112 §§6.2–6.3 and §8 | | RFC 9562 | New persistence identifiers use UUIDv7 | Initial migration | @@ -17,7 +17,7 @@ | SLSA 1.2 / SPDX 2.3 / GitHub artifact attestations | Exact-head package evidence builds the wheel twice from a source-derived `SOURCE_DATE_EPOCH`, requires byte-identical SHA-256 digests, emits a deterministic SPDX 2.3 SBOM plus `source-provenance.json`, and makes `SHA256SUMS` cover the wheel, SBOM and source-provenance manifest. After checksum verification, the rebuilt wheel is installed with `--require-hashes` from a requirements line that carries the measured `--hash=sha256:` digest. The intermediate public-API smoke test imports the source tree over `PYTHONPATH` instead of an unhashed editable install. The manifest binds the verified source SHA to the wheel digest and SBOM digest before merge. Pull-request-controlled build/test code runs with `contents: read` only; OIDC, attestation and artifact-metadata write permissions are isolated in a distinct push-only `integrated-attestations` job. That job depends on the successful foundation build, downloads the immutable SHA-named evidence bundle, re-verifies checksums and `source_sha == github.sha`, and only then creates GitHub OIDC-backed signed provenance and SBOM attestations on integrated `develop`/`main` heads. A new runtime dependency fails closed until the SBOM generator represents its dependency relationship. This is evidence readiness, not a claimed SLSA level or certification | Accounting Foundation CI, `scripts/generate_supply_chain_evidence.py`, supply-chain evidence tests, GitHub workflow-permissions/OIDC/artifact-attestation guidance, and ADR 0048 | | OSV-Scanner / OSV.dev vulnerability data | Pull-request dependency evidence is tied to the immutable PR head and an independently fetched live base tip. The gate records dependency-manifest diffs and SHA-256 values, rejects stale/non-ancestor base identity, and scans the complete hash-locked exact-head Python dependency set with a digest-pinned OSV-Scanner image. A known vulnerability, scanner failure, skipped/unavailable evidence path or wrong checkout identity is non-passing; aggregate organization workflow success cannot substitute for an unexecuted dependency-review step | `exact-head-dependency-diff` CI job, `tests/test_dependency_review_contract.py`, OSV-Scanner source/lockfile guidance, and ADR 0048 | | AICPA Trust Services Criteria (SOC 2) | Auditors read an append-only history of posted, reversed, and closed facts from existing `outbox_event` rows, including already-published rows, without marking publish. Controllers also list stored `journal_reversal` lineage and durable hard-close receipts over HTTP without SQL. A HomeTax filing command fail-closes and persists a rejected receipt when the VAT register or the purpose-limited HomeTax credential is missing, and this slice never claims `transmitted` | HTTP audit-event history, HTTP journal-reversal list, HTTP period-close list, HTTP fail-closed HomeTax submission, ADR 0027, ADR 0029, ADR 0030, and ADR 0046 | -| CWL purpose-bound authorization contract | Tenant identity is not authority: a trusted host adapter supplies validated opaque principal, explicit `principal_kind` (`human`, `service`, or `agent`), purpose, permission, and authentication evidence; every route maps to a versioned operation decision; high-impact agent/model requests fail closed; and each accepted or denied decision retains both principal and requested tenant references in append-only tenant-scoped evidence without raw credentials. The persistence boundary rejects requested-tenant evidence outside its storage scope and rejects caller-constructed or mutated decisions that were not issued unchanged by the authorization evaluator. | `AuthenticatedPrincipal`, `AuthorizationDecision`, `record_authorization_decision`, route authorization regressions, and copied/mutated-decision regressions, migration 0015, and ADR 0055 | +| CWL purpose-bound authorization contract | Tenant identity is not authority: a trusted host adapter supplies validated opaque principal, explicit `principal_kind` (`human`, `service`, or `agent`), purpose, permission, and authentication evidence; every route maps to a versioned operation decision; high-impact agent/model requests fail closed; and each accepted or denied decision retains both principal and requested tenant references in append-only tenant-scoped evidence without raw credentials. The persistence boundary rejects requested-tenant evidence outside its storage scope and rejects caller-constructed or mutated decisions that were not issued unchanged by the authorization evaluator. | `AuthenticatedPrincipal`, `AuthorizationDecision`, `record_authorization_decision`, route authorization regressions, and copied/mutated-decision regressions, migration 0015, and ADR 0064 | | W3C PROV-O | Preserve entity, activity, agent, derivation, and attribution references across source proposal, posting, and append-only journal reversal lineage. The in-memory posting oracle scopes those identities by tenant and refuses to overwrite a posted `proposal_id`. Checked-in migrations cannot `UPDATE` or `DELETE` `general_journal` or `journal_entry_line` | Source-reference and receipt contracts, HTTP journal-reversal list, repository migration validation, ADR 0003, and ADR 0029 | | ISO/IEC/IEEE 42010:2022 | Keep stakeholder concerns, authority boundaries, architecture views, and decisions explicit | Architecture and ADR set | | JSON Schema Draft 2020-12 | Close external objects, version contracts, reject extra fields, and validate exact value formats. Policy-manifest `account_mappings` are unique by `account_role_code` before policy load because `uniqueItems` compares whole objects | Three contract schemas, `load_accounting_policy`, ADR 0005, and ADR 0007 |