diff --git a/CHANGELOG.md b/CHANGELOG.md index f54fc4ae..da949311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +- The accounting HTTP boundary now resolves a validated principal independently for every request through a trusted `request_principal_resolver`; the public server factory no longer accepts one reusable principal whose permissions every reachable client could inherit. Identity-adapter failures fail closed before an allowed audit decision is written. +- Authorization evidence persistence now accepts only unchanged decisions issued by the + authorization evaluator; caller-constructed or post-issuance-mutated `allowed` decisions + cannot be promoted into durable audit evidence, while copied evaluator decisions retain + their provenance. +- Malformed or non-object period-close request bodies no longer create an allowed hard-close authorization record before the request is rejected; authorization evidence is reserved for a structurally valid close command. + +- Authorization principals now require an explicit `principal_kind` (`human`, `service`, or + `agent`) so omitted host identity classification cannot silently receive human high-impact + authority. Authorization evidence retains both principal and requested tenant references, and + unprovisioned tenant diagnostics remain fail-closed without being mislabeled as audit-store loss. +- Authorization evidence now rejects a requested tenant that differs from the tenant scope used for + persistence, preventing cross-scope audit claims at the application evidence boundary. +- Added purpose-bound application authorization at the HTTP boundary: a trusted host adapter must + supply validated opaque principal/purpose evidence and every accounting route maps to an explicit + permission before domain work. Missing, unknown, tenant-mismatched, insufficient, and + agent-originated high-impact decisions fail closed; immutable tenant-scoped decision evidence is + retained by migration 0015. The tenant header remains identity binding, not caller authority. - Added the deterministic bank-reconciliation proposal engine from ADR 0054: stable provider, end-to-end, and account-servicer references take precedence; exact decimal amount and currency evidence must agree; exact-money plus bounded-date fallback is permitted only for one unique candidate; and ambiguity, conflicts, out-of-window evidence, or no candidate produce explicit abstention with an operator next action. Match proposals are read-only evidence with no automatic journal posting; any accounting adjustment must re-enter the existing journal command boundary. - Added the exact book-to-bank reconciliation bridge with exact Decimal equations, fail-closed one minor unit differences, statement-population and book-population provenance, no automatic journal posting, and ADR 0054. - Added a read-only reconciliation close-review projection over deterministic decisions and the exact bridge: controllers receive exact bank/book/reconciled/outstanding/unexplained Decimal values, immutable run and population provenance, preceding-run deltas, unresolved statement-entry references, and an explicit next action. JSON and CSV exports preserve money as decimal strings. `suitable_for_period_close_review` is evidence eligibility only; the projection cannot approve reconciliation, close a period, or post a journal. ADR 0054 records the authority boundary. @@ -15,7 +33,7 @@ ## [0.1.0] - 2026-08-26 -First tagged release of the accounting system of record foundation: exact-decimal proposal validation, idempotent posting, append-only reversal, trial balance, financial statements, fiscal-period close control, VAT/HomeTax fail-closed evidence, durable outbox, tenant-scoped row-level security, immutable ISO 20022 camt.053.001.14 bank-statement evidence registry, and action-guiding caller-facing copy. Pre-Alpha (Development Status :: 3 - Alpha planned next); foreign exchange, deterministic reconciliation matching, purpose-bound authorization, and live NTS transmission remain explicit future scope. +First tagged release of the accounting system of record foundation: exact-decimal proposal validation, idempotent posting, append-only reversal, trial balance, financial statements, fiscal-period close control, VAT/HomeTax fail-closed evidence, durable outbox, tenant-scoped row-level security, immutable ISO 20022 camt.053.001.14 bank-statement evidence registry, and action-guiding caller-facing copy. Pre-Alpha (Development Status :: 3 - Alpha planned next); foreign exchange, deterministic reconciliation matching, and live NTS transmission remain explicit future scope. - Hardened every caller-facing message against the two product-writing rules: no internal implementation boundary (driver module, internal class or schema-object names) reaches a customer-visible error, and every failure names the customer's next action ("Supply …, then retry …"). The database-driver absence path now routes to the platform operator instead of naming internals; tenant-scope and runtime-binding failures describe the deployment fact rather than internal objects; unmapped account roles, unbalanced proposals, reversal conflicts, and close-key reuse all carry explicit next-action guidance. 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..c9ec393c --- /dev/null +++ b/database/migrations/0015_authorization_decision_evidence.sql @@ -0,0 +1,101 @@ +BEGIN; + +-- Purpose-bound application decisions are append-only evidence. The host identity adapter validates +-- credentials; this table retains only the opaque claims and decision needed for accounting audit. +-- Identity references are normalized CWL URNs and use the 255-octet authorization profile ceiling; +-- raw external claims remain at the trusted identity-provider boundary. Operation/purpose limits +-- mirror the executable code contract, permission is two bounded code components, and the existing +-- 512-character correlation evidence ceiling is enforced again at PostgreSQL so direct SQL cannot +-- inflate storage while multibyte command identities retain the same contract as the HTTP boundary. +CREATE TABLE accounting_integration.authorization_decision_record ( + authorization_decision_record_id uuid PRIMARY KEY DEFAULT uuidv7(), + tenant_account_id uuid NOT NULL, + principal_reference text NOT NULL + CHECK ( + btrim(principal_reference) <> '' + AND octet_length(principal_reference) <= 255 + AND principal_reference ~ '^urn:cwl:[A-Za-z0-9_:.-]+$' + ), + principal_tenant_reference text NOT NULL + CHECK ( + btrim(principal_tenant_reference) <> '' + AND octet_length(principal_tenant_reference) <= 255 + AND principal_tenant_reference ~ '^urn:cwl:[A-Za-z0-9_:.-]+$' + ), + requested_tenant_reference text NOT NULL + CHECK ( + btrim(requested_tenant_reference) <> '' + AND octet_length(requested_tenant_reference) <= 255 + AND requested_tenant_reference ~ '^urn:cwl:[A-Za-z0-9_:.-]+$' + ), + authentication_context_reference text NOT NULL + CHECK ( + btrim(authentication_context_reference) <> '' + AND octet_length(authentication_context_reference) <= 255 + AND authentication_context_reference ~ '^urn:cwl:[A-Za-z0-9_:.-]+$' + ), + credential_evidence_reference text NOT NULL + CHECK ( + btrim(credential_evidence_reference) <> '' + AND octet_length(credential_evidence_reference) <= 255 + AND credential_evidence_reference ~ '^urn:cwl:[A-Za-z0-9_:.-]+$' + ), + operation_code text NOT NULL + CHECK ( + octet_length(operation_code) <= 64 + AND operation_code ~ '^[a-z][a-z0-9_]{1,63}$' + ), + permission_code text NOT NULL + CHECK ( + octet_length(permission_code) <= 129 + AND ( + permission_code = '' + OR permission_code ~ '^[a-z][a-z0-9_]{1,63}\.[a-z][a-z0-9_]{1,63}$' + ) + ), + purpose_code text NOT NULL + CHECK ( + octet_length(purpose_code) <= 64 + AND purpose_code ~ '^[a-z][a-z0-9_]{1,63}$' + ), + policy_version text NOT NULL + CHECK (btrim(policy_version) <> '' AND octet_length(policy_version) <= 64), + decision_code text NOT NULL CHECK (decision_code IN ('allowed', 'denied')), + correlation_reference text NOT NULL + CHECK (btrim(correlation_reference) <> '' AND char_length(correlation_reference) <= 512), + recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(), + FOREIGN KEY (tenant_account_id) + REFERENCES accounting_core.tenant_account (tenant_account_id), + UNIQUE (tenant_account_id, authorization_decision_record_id) +); + +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..305c3aad 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 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 @@ -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..b153019e 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`, and must pass an explicit `principal_kind` of +`human`, `service`, or `agent`. AIS rejects an omitted kind rather than classifying it as a human. +Provide `request_principal_resolver` as the trusted host adapter: it validates each incoming request and returns that request's `AuthenticatedPrincipal`. A static server-wide principal is not supported. The standalone runner supplies no resolver and therefore denies every accounting route except `/healthz`. Grant the runtime login INSERT access to +`accounting_integration.authorization_decision_record` and retain its append-only authorization +decision evidence. Never forward bearer tokens, request-body permission claims, or model output. + ## Database installation Apply, in order: @@ -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..f291f056 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -25,6 +25,18 @@ Database administration is not business posting authority. Migration owners and Purpose-bound application authorization is a separate control from PostgreSQL privileges. Request-body fields, model text, headers supplied by an untrusted client and database GUC values cannot grant posting, reversal, close or tax authority. +The trusted host identity adapter must validate issuer, audience, expiry, signature, and token binding for every request before returning that request's `AuthenticatedPrincipal` to AIS. The production HTTP factory accepts a request-scoped resolver rather than a reusable server-wide principal. It must pass the explicit `principal_kind` value +`human`, `service`, or `agent`; AIS has no implicit kind default, so omission is rejected before +authorization. The HTTP boundary maps each route to a stable operation and requires the corresponding +versioned permission; soft-close and hard-close are independent permissions. Missing, unknown, +tenant-mismatched, insufficient, or agent-originated high-impact decisions fail closed before +`accept` or `lookup` executes. Each decision is appended to tenant-scoped forced-RLS +`accounting_integration.authorization_decision_record`, including both principal and requested tenant +references, without raw tokens or full policy documents. The persistence boundary rejects a record +whose requested tenant differs from its storage tenant and accepts only unchanged decisions issued +by the authorization evaluator, preventing caller-constructed or mutated allow evidence. The +standalone runner has no principal by default and denies all accounting routes except health status. + ## PostgreSQL runtime identities Production runtime access uses a non-owner, non-superuser, non-`BYPASSRLS` login with only the table / schema privileges required by supported application paths. Tenant-scoped authoritative tables both enable and `FORCE ROW LEVEL SECURITY`; the runtime identity is still deliberately non-owner so ordinary service access never depends on owner-bypass semantics. Real PostgreSQL integration tests must prove an actual restricted login can execute a supported same-tenant posting/read path while cross-tenant rows remain invisible and the login is neither owner, superuser nor `BYPASSRLS`. 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. 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..b99e6f7a --- /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 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 + +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://doi.org/10.1016/j.jisa.2025.103997 + +## 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. 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..46a71254 --- /dev/null +++ b/docs/doctoring/2026-09-01-request-scoped-principal-authority.md @@ -0,0 +1,47 @@ +# 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 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. + +## 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. 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..33d19130 --- /dev/null +++ b/docs/doctoring/2026-09-02-authorization-evidence-storage-bounds.md @@ -0,0 +1,46 @@ +# 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-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 + +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. +- 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 + +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. 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..f8dc779a --- /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 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. + +## 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. diff --git a/docs/doctoring/REFERENCES.md b/docs/doctoring/REFERENCES.md index 8fd3f3f0..0c38ff57 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/ @@ -30,28 +32,38 @@ 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 +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 + +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 -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. (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 +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://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 diff --git a/docs/doctoring/STANDARD_TRACEABILITY.md b/docs/doctoring/STANDARD_TRACEABILITY.md index c35fd19c..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,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, 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 | 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 | diff --git a/scripts/validate_repository.py b/scripts/validate_repository.py index 99299c4f..8caa3568 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/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", 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..f1ada711 --- /dev/null +++ b/src/accounting_information_platform/authorization.py @@ -0,0 +1,268 @@ +"""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 hashlib +import hmac +import re +from dataclasses import dataclass, field +from secrets import token_bytes +from types import MappingProxyType +from typing import Mapping + +from .core import _require_code, _require_reference + + +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")) + + +_AUTHORIZATION_DECISION_SEAL_KEY = token_bytes(32) +_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( + { + "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", + "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", + "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", + "complete_reconciliation", + "resolve_reconciliation_exception", + "publish_outbox", + "submit_tax_artifact", + "manage_bank_account", + "ingest_bank_statement", + } +) + + +@dataclass(frozen=True, slots=True) +class AuthenticatedPrincipal: + """Validated opaque identity claims with an explicit human, service, or agent kind.""" + + 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 + + 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 + _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 hmac.new( + _AUTHORIZATION_DECISION_SEAL_KEY, + repr(values).encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def _issue_authorization_decision(**values: object) -> AuthorizationDecision: + """Create decision evidence only through the policy evaluator.""" + decision = AuthorizationDecision(**values) + object.__setattr__(decision, "_decision_fingerprint", _decision_fingerprint(decision)) + return decision + + +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 _issue_authorization_decision( + 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 _issue_authorization_decision( + 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") + 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 .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, 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, %s) + """, + ( + tenant_id, + decision.principal_reference, + decision.tenant_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, + ), + ) \ No newline at end of file diff --git a/src/accounting_information_platform/http_api.py b/src/accounting_information_platform/http_api.py index 20d5d9c4..c53f17e4 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, + 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.request_principal_resolver = request_principal_resolver 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,58 @@ 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 + 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 + try: + record_authorization_decision( + self.server.database_url, + self.server.tenant_reference, + decision, + correlation_reference, + ) + except AccountingValidationError as error: + self._write_error(503, str(error)) + return False + 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,19 +1928,60 @@ 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 period_close_operation(None) + if not isinstance(payload, dict): + return period_close_operation(None) + return period_close_operation(payload.get("period_status_code")) + + +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) + and len(f"{field}:{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, + 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) + return JournalProposalServer( + (host, port), database_url, tenant_reference, request_principal_resolver + ) def run_journal_proposal_server( @@ -1790,8 +1990,9 @@ def run_journal_proposal_server( host: str | None = None, port: int | None = None, serve: Callable[[], None] | 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 @@ -1814,7 +2015,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, + request_principal_resolver, ) 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..1892c4fd --- /dev/null +++ b/tests/test_authorization.py @@ -0,0 +1,402 @@ +"""Unit contracts for the purpose-bound application authorization port.""" + +from __future__ import annotations + +import copy +import json +import unittest +from dataclasses import replace +from email.message import Message +from types import SimpleNamespace +import unittest.mock as mock + +from accounting_information_platform import AccountingValidationError +from accounting_information_platform.authorization import ( + AUTHORIZATION_POLICY_VERSION, + AuthenticatedPrincipal, + AuthorizationDecision, + 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", + 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: + """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, + request_principal_resolver=lambda _request: 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", + 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" + ): + 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("/period-closes", b"[]"), + "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", + ) + 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.""" + decision = authorize(principal("accounting.read_catalog"), TENANT, "read_catalog") + 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_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_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_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") + 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 = ( + "_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_authorization_adr_governance.py b/tests/test_authorization_adr_governance.py new file mode 100644 index 00000000..d30d7e46 --- /dev/null +++ b/tests/test_authorization_adr_governance.py @@ -0,0 +1,42 @@ +"""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") +STANDARD_TRACEABILITY = Path("docs/doctoring/STANDARD_TRACEABILITY.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) + + 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() diff --git a/tests/test_authorization_evidence_storage_contract.py b/tests/test_authorization_evidence_storage_contract.py new file mode 100644 index 00000000..a1a54c21 --- /dev/null +++ b/tests/test_authorization_evidence_storage_contract.py @@ -0,0 +1,69 @@ +"""Repository contracts for bounded durable authorization-decision evidence.""" + +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") + + +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("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}$'", + text, + ) + 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 = "한" * 166 + 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() diff --git a/tests/test_database_migration_contracts.py b/tests/test_database_migration_contracts.py index e77d9a6f..32d6acd4 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,23 @@ 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("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) + 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..9ca4077e 100644 --- a/tests/test_postgres_posting.py +++ b/tests/test_postgres_posting.py @@ -68,6 +68,11 @@ ) import psycopg +from accounting_information_platform.authorization import ( + AuthenticatedPrincipal, + authorize, + record_authorization_decision, +) from accounting_information_platform.persistence import ( _fiscal_year_identity, apply_foundation_migration, @@ -4501,7 +4506,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 +11993,222 @@ 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", + principal_kind="human", + ) + 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, principal_tenant_reference + 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", 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 ( + "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_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" + 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( + 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", + principal_kind="human", + ) + 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()), + ) + 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(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_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,), + ).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: """Unmapped roles, missing books, and closed periods write no durable rows.""" self._delete_role_mapping("tax_payable") @@ -13180,12 +13401,49 @@ 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", + principal_kind="human", + ) 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, + request_principal_resolver=lambda _request: authorization_context, ) thread = Thread(target=server.serve_forever, daemon=True) thread.start() diff --git a/tests/test_reconciliation_completion_authorization_contract.py b/tests/test_reconciliation_completion_authorization_contract.py new file mode 100644 index 00000000..aecc88d5 --- /dev/null +++ b/tests/test_reconciliation_completion_authorization_contract.py @@ -0,0 +1,75 @@ +"""Authorization contracts for the reconciliation-completion buyer 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-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_versioned_permission(self) -> None: + """The completion command must not inherit 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, "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.""" + 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() 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() diff --git a/tests/test_request_scoped_authorization_context.py b/tests/test_request_scoped_authorization_context.py new file mode 100644 index 00000000..6d2da810 --- /dev/null +++ b/tests/test_request_scoped_authorization_context.py @@ -0,0 +1,137 @@ +"""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], + ) + + 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()