From 13cd665ffc683d59e83bca70f370080067ef3fe9 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 15 Jul 2026 06:20:15 +0100 Subject: [PATCH 1/7] Implement canonical actor profile registry --- .../WS-AUTH-001-06-canonical-actor-profile.md | 22 +- .agent-loop/merge-intents/WS-AUTH-001-06.json | 9 + .../versions/0020_canonical_actor_profile.py | 387 ++++++ backend/app/adapters/auth/dev.py | 11 +- backend/app/adapters/auth/flow.py | 11 +- backend/app/api/deps/api_controls.py | 55 +- backend/app/api/deps/auth.py | 100 +- backend/app/api/deps/rate_controls.py | 58 + backend/app/api/router.py | 3 +- backend/app/api/routes/auth.py | 41 +- backend/app/db/models.py | 7 +- backend/app/modules/actors/models.py | 176 ++- backend/app/modules/actors/repository.py | 208 +-- backend/app/modules/actors/schemas.py | 81 +- backend/app/modules/actors/service.py | 723 +++++----- backend/app/modules/tasks/router.py | 38 +- backend/app/modules/tasks/service.py | 34 +- backend/app/schemas/auth.py | 9 +- backend/scripts/api_contract_e2e.py | 12 +- backend/tests/test_actors.py | 1218 ++++++++--------- backend/tests/test_alembic.py | 860 ++++++++++-- backend/tests/test_api_rate_controls.py | 10 - backend/tests/test_auth.py | 85 +- backend/tests/test_checkers.py | 30 + backend/tests/test_projects.py | 59 +- backend/tests/test_tasks.py | 243 ++-- docs/operations_authorization_service.md | 157 ++- 27 files changed, 3083 insertions(+), 1564 deletions(-) create mode 100644 .agent-loop/merge-intents/WS-AUTH-001-06.json create mode 100644 backend/alembic/versions/0020_canonical_actor_profile.py create mode 100644 backend/app/api/deps/rate_controls.py diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-06-canonical-actor-profile.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-06-canonical-actor-profile.md index ca3bffda..345fced7 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-06-canonical-actor-profile.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-06-canonical-actor-profile.md @@ -33,10 +33,15 @@ P1 ```text backend/app/modules/actors/** +backend/app/adapters/auth/dev.py +backend/app/adapters/auth/flow.py backend/app/modules/tasks/service.py backend/app/modules/tasks/router.py backend/app/modules/tasks/schemas.py backend/app/api/deps/auth.py +backend/app/api/deps/api_controls.py +backend/app/api/deps/rate_controls.py +backend/app/api/router.py backend/app/api/routes/auth.py backend/app/schemas/auth.py backend/app/db/models.py @@ -44,6 +49,7 @@ backend/app/modules/audit/** backend/alembic/versions/0020_*.py backend/tests/test_actors.py backend/tests/test_auth.py +backend/tests/test_api_rate_controls.py backend/tests/test_alembic.py backend/tests/test_projects.py backend/tests/test_tasks.py @@ -51,6 +57,7 @@ backend/tests/test_checkers.py backend/scripts/api_contract_e2e.py docs/operations_authorization_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** +.agent-loop/merge-intents/WS-AUTH-001-06.json .agent-loop/LOOP_STATE.md .agent-loop/WORK_QUEUE.md .agent-loop/REVIEW_LOG.md @@ -93,8 +100,11 @@ review or compensation models chunk 13 removes its queue/claim/start consumers after grant provisioning; chunk 14 removes the route, activation service/schema, token-role observation fields, final submission consumer, and compatibility adapter together. -- Provisioning uses the established idempotency/audit/invalidation foundation - and commits the profile, link, and events atomically. +- First-human provisioning is transactionally idempotent through exact + `(issuer, subject)` uniqueness and concurrent-conflict resolution. Profile, + link, `ActorProfileProvisioned`, and `ActorIdentityLinked` evidence commit + atomically through the shared audit path. Automatic first access creates no + client-key authority-idempotency record and no invalidation event. - `/api/v1/actors/me` returns Contributor domain with no implied project/admin authority. - `GET /api/v1/actors/me` and `PATCH /api/v1/actors/me` have request, privacy, @@ -107,6 +117,8 @@ review or compensation models classified upgrade, and preserved attribution. Downgrade/rollback must succeed after the external classification envelope is deleted post-upgrade, using only its durably recorded version/checksum and migration state. +- Behavior tests keep the materially changed actor subsystem at or above 90 + percent branch coverage without exclusions or weakened assertions. ## Verification commands @@ -115,7 +127,11 @@ review or compensation models (cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/alembic downgrade -1) (cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/alembic upgrade head) (cd backend && .venv/bin/python -m ruff check app tests) -(cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python -m pytest -q) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/python -m pytest -q \ + --cov=app.modules.actors --cov-branch --cov-report=term-missing \ + --cov-fail-under=90 tests/test_actor_legacy_classification.py \ + tests/test_actors.py tests/test_auth.py tests/test_tasks.py) +(cd backend && WORKSTREAM_TEST_DATABASE_URL= .venv/bin/python -m pytest -q) (cd backend && WORKSTREAM_DATABASE_URL= .venv/bin/python scripts/api_contract_e2e.py) python3 scripts/check_stale_workstream_wording.py python3 scripts/check_markdown_links.py diff --git a/.agent-loop/merge-intents/WS-AUTH-001-06.json b/.agent-loop/merge-intents/WS-AUTH-001-06.json new file mode 100644 index 00000000..70a5996b --- /dev/null +++ b/.agent-loop/merge-intents/WS-AUTH-001-06.json @@ -0,0 +1,9 @@ +{ + "chunk_id": "WS-AUTH-001-06", + "chunk_title": "Canonical Actor Profile And Identity Link", + "initiative_id": "WS-AUTH-001", + "next_chunk_id": "WS-AUTH-001-07", + "next_chunk_title": "Authorization Kernel And Permission Registry", + "next_requires_explicit_start": true, + "schema_version": 1 +} diff --git a/backend/alembic/versions/0020_canonical_actor_profile.py b/backend/alembic/versions/0020_canonical_actor_profile.py new file mode 100644 index 00000000..5312500c --- /dev/null +++ b/backend/alembic/versions/0020_canonical_actor_profile.py @@ -0,0 +1,387 @@ +"""migrate classified actors to canonical profiles and identity links + +Revision ID: 0020_canonical_actor_profile +Revises: 0019_authority_idempotency +Create Date: 2026-07-15 +""" + +from __future__ import annotations + +from uuid import NAMESPACE_URL, uuid5 + +from alembic import op +from pydantic import ValidationError +import sqlalchemy as sa + +from app.modules.actors.legacy_classification import ( + LegacyClassificationError, + LegacyActorRow, + database_binding_identifier, + load_migration_envelope_from_environment, + source_row_set_sha256, +) + +revision = "0020_canonical_actor_profile" +down_revision = "0019_authority_idempotency" +branch_labels = depends_on = None + +UUID_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + + +def _legacy_rows(bind) -> tuple[LegacyActorRow, ...]: + rows = bind.execute( + sa.text( + "select actor_id, external_issuer, external_subject " + "from actor_identities order by actor_id" + ) + ).all() + try: + return tuple( + LegacyActorRow(actor_id=row[0], issuer=row[1], subject=row[2]) for row in rows + ) + except ValidationError: + raise LegacyClassificationError("invalid_source_rows") from None + + +def _classification(bind, rows: tuple[LegacyActorRow, ...]): + if not rows: + return None + database_name, database_oid = bind.execute( + sa.text( + "select current_database(), oid from pg_database " + "where datname = current_database()" + ) + ).one() + return load_migration_envelope_from_environment( + rows, + database_binding=database_binding_identifier(database_name, database_oid), + ) + + +def _rename_legacy_tables() -> None: + op.rename_table("actor_profiles", "legacy_workflow_eligibility") + op.rename_table("actor_identities", "legacy_actor_identities") + statements = ( + "alter table legacy_actor_identities rename constraint pk_actor_identities to pk_legacy_actor_identities", + "alter table legacy_actor_identities rename constraint uq_actor_identities_external_identity to uq_legacy_actor_identities_external_identity", + "alter table legacy_workflow_eligibility rename constraint pk_actor_profiles to pk_legacy_workflow_eligibility", + "alter table legacy_workflow_eligibility rename constraint ck_actor_profiles_ck_actor_profiles_profile_type to ck_legacy_workflow_eligibility_profile_type", + "alter table legacy_workflow_eligibility rename constraint ck_actor_profiles_ck_actor_profiles_status to ck_legacy_workflow_eligibility_status", + "alter table legacy_workflow_eligibility rename constraint uq_actor_profiles_actor_type_scope to uq_legacy_workflow_eligibility_actor_type_scope", + "alter table legacy_workflow_eligibility rename constraint fk_actor_profiles_actor_id_actor_identities to fk_legacy_workflow_eligibility_actor_id_legacy_actor_identities", + "alter index ix_actor_profiles_actor_id rename to ix_legacy_workflow_eligibility_actor_id", + "alter index ix_actor_profiles_profile_type rename to ix_legacy_workflow_eligibility_profile_type", + "alter index ix_actor_profiles_status rename to ix_legacy_workflow_eligibility_status", + ) + for statement in statements: + op.execute(statement) + + +def _create_canonical_tables() -> None: + op.create_table( + "actor_profiles", + sa.Column("id", sa.String(36), nullable=False), + sa.Column("actor_kind", sa.String(16), nullable=False), + sa.Column("status", sa.String(16), nullable=False), + sa.Column("provisioning_method", sa.String(32), nullable=False), + sa.Column("display_name", sa.String(200)), + sa.Column("contact_email", sa.String(320)), + sa.Column("created_by", sa.String(120), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True)), + sa.Column("suspended_by", sa.String(120)), + sa.Column("suspended_at", sa.DateTime(timezone=True)), + sa.Column("suspension_reason", sa.String(500)), + sa.Column("deactivated_by", sa.String(120)), + sa.Column("deactivated_at", sa.DateTime(timezone=True)), + sa.Column("deactivation_reason", sa.String(500)), + sa.PrimaryKeyConstraint("id", name=op.f("pk_actor_profiles")), + sa.CheckConstraint(f"id ~ '{UUID_PATTERN}'", name=op.f("ck_actor_profiles_id_uuid")), + sa.CheckConstraint("actor_kind in ('human','service')", name=op.f("ck_actor_profiles_actor_kind")), + sa.CheckConstraint("status in ('active','suspended','deactivated')", name=op.f("ck_actor_profiles_status")), + sa.CheckConstraint( + "provisioning_method in ('automatic_first_access','manual_service_provisioning')", + name=op.f("ck_actor_profiles_provisioning_method"), + ), + sa.CheckConstraint( + "(actor_kind='human' and provisioning_method='automatic_first_access') or " + "(actor_kind='service' and provisioning_method='manual_service_provisioning')", + name=op.f("ck_actor_profiles_kind_provisioning"), + ), + sa.CheckConstraint( + "(status='active' and suspended_by is null and suspended_at is null and " + "suspension_reason is null and deactivated_by is null and deactivated_at is null " + "and deactivation_reason is null) or " + "(status='suspended' and suspended_by is not null and suspended_at is not null " + "and suspension_reason is not null and deactivated_by is null and " + "deactivated_at is null and deactivation_reason is null) or " + "(status='deactivated' and deactivated_by is not null and deactivated_at is not null " + "and deactivation_reason is not null)", + name=op.f("ck_actor_profiles_lifecycle_fields"), + ), + ) + op.create_index( + op.f("ix_actor_profiles_status_actor_kind"), + "actor_profiles", + ["status", "actor_kind"], + ) + op.create_index( + op.f("ix_actor_profiles_last_seen_at"), + "actor_profiles", + ["last_seen_at"], + ) + + op.create_table( + "actor_identity_links", + sa.Column("id", sa.String(36), nullable=False), + sa.Column("actor_profile_id", sa.String(36), nullable=False), + sa.Column("issuer", sa.String(200), nullable=False), + sa.Column("subject", sa.String(200), nullable=False), + sa.Column("subject_kind", sa.String(16), nullable=False), + sa.Column("status", sa.String(16), nullable=False), + sa.Column("linked_by", sa.String(120), nullable=False), + sa.Column("linked_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("last_verified_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("revoked_by", sa.String(120)), + sa.Column("revoked_at", sa.DateTime(timezone=True)), + sa.Column("revoked_reason", sa.String(500)), + sa.Column("reactivated_by", sa.String(120)), + sa.Column("reactivated_at", sa.DateTime(timezone=True)), + sa.Column("reactivation_reason", sa.String(500)), + sa.PrimaryKeyConstraint("id", name=op.f("pk_actor_identity_links")), + sa.ForeignKeyConstraint( + ["actor_profile_id"], ["actor_profiles.id"], name=op.f("fk_actor_identity_links_actor_profile_id_actor_profiles") + ), + sa.UniqueConstraint("issuer", "subject", name=op.f("uq_actor_identity_links_external_identity")), + sa.UniqueConstraint("actor_profile_id", name=op.f("uq_actor_identity_links_actor_profile")), + sa.CheckConstraint(f"id ~ '{UUID_PATTERN}'", name=op.f("ck_actor_identity_links_id_uuid")), + sa.CheckConstraint("length(btrim(issuer)) between 1 and 200", name=op.f("ck_actor_identity_links_issuer")), + sa.CheckConstraint("length(btrim(subject)) between 1 and 200", name=op.f("ck_actor_identity_links_subject")), + sa.CheckConstraint("subject_kind in ('human','service')", name=op.f("ck_actor_identity_links_subject_kind")), + sa.CheckConstraint("status in ('active','revoked')", name=op.f("ck_actor_identity_links_status")), + sa.CheckConstraint( + "(status='active' and revoked_by is null and revoked_at is null and revoked_reason is null) or " + "(status='revoked' and revoked_by is not null and revoked_at is not null and revoked_reason is not null)", + name=op.f("ck_actor_identity_links_revocation_fields"), + ), + ) + op.create_index( + op.f("ix_actor_identity_links_issuer_subject_status"), + "actor_identity_links", + ["issuer", "subject", "status"], + ) + + +def _install_guards() -> None: + op.execute( + """ + create function guard_actor_profile_history() returns trigger language plpgsql as $$ + begin + if tg_op='DELETE' then raise exception 'actor profiles are immutable history' using errcode='55000'; end if; + if (new.id,new.actor_kind,new.provisioning_method,new.created_by,new.created_at) + is distinct from + (old.id,old.actor_kind,old.provisioning_method,old.created_by,old.created_at) then + raise exception 'actor profile identity is immutable' using errcode='55000'; + end if; + if old.status='deactivated' and new.status <> 'deactivated' then + raise exception 'deactivated actor is terminal' using errcode='23514'; + end if; + new.updated_at = statement_timestamp(); return new; + end $$ + """ + ) + op.execute( + "create trigger actor_profile_history_guard before update or delete on actor_profiles " + "for each row execute function guard_actor_profile_history()" + ) + op.execute( + """ + create function guard_actor_identity_link_history() returns trigger language plpgsql as $$ + begin + if tg_op='DELETE' then raise exception 'actor identity links are immutable history' using errcode='55000'; end if; + if (new.id,new.actor_profile_id,new.issuer,new.subject,new.subject_kind,new.linked_by,new.linked_at) + is distinct from + (old.id,old.actor_profile_id,old.issuer,old.subject,old.subject_kind,old.linked_by,old.linked_at) then + raise exception 'actor identity link anchor is immutable' using errcode='55000'; + end if; + return new; + end $$ + """ + ) + op.execute( + "create trigger actor_identity_link_history_guard before update or delete on actor_identity_links " + "for each row execute function guard_actor_identity_link_history()" + ) + op.execute( + """ + create function validate_canonical_actor_link() returns trigger language plpgsql as $$ + declare profile_row actor_profiles%rowtype; link_count integer; + begin + if tg_table_name='actor_profiles' then + select count(*) into link_count from actor_identity_links where actor_profile_id=new.id; + if link_count <> 1 then raise exception 'actor profile requires exactly one identity link' using errcode='23514'; end if; + if not exists(select 1 from actor_identity_links where actor_profile_id=new.id and subject_kind=new.actor_kind) then + raise exception 'actor and identity kind mismatch' using errcode='23514'; + end if; + else + select * into profile_row from actor_profiles where id=new.actor_profile_id; + if not found or profile_row.actor_kind <> new.subject_kind then + raise exception 'actor and identity kind mismatch' using errcode='23514'; + end if; + end if; return new; + end $$ + """ + ) + op.execute( + "create constraint trigger actor_profile_link_guard after insert or update on actor_profiles " + "deferrable initially deferred for each row execute function validate_canonical_actor_link()" + ) + op.execute( + "create constraint trigger actor_identity_link_profile_guard after insert or update on actor_identity_links " + "deferrable initially deferred for each row execute function validate_canonical_actor_link()" + ) + + +def upgrade() -> None: + """Consume classified legacy evidence and install one canonical actor root.""" + bind = op.get_bind() + bind.execute(sa.text("lock table actor_profiles in access exclusive mode")) + bind.execute(sa.text("lock table actor_identities in access exclusive mode")) + rows = _legacy_rows(bind) + envelope = _classification(bind, rows) + kinds = {entry.actor_id: entry.subject_kind for entry in envelope.classifications} if envelope else {} + + _rename_legacy_tables() + _create_canonical_tables() + op.create_table( + "actor_profile_migration_state", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("schema_version", sa.Integer(), nullable=False), + sa.Column("classified_count", sa.Integer(), nullable=False), + sa.Column("source_row_set_sha256", sa.String(64), nullable=False), + sa.Column("manifest_sha256", sa.String(64)), + sa.Column("envelope_sha256", sa.String(64)), + sa.Column("migrated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_actor_profile_migration_state")), + sa.CheckConstraint("id=1 and schema_version=1 and classified_count >= 0", name=op.f("ck_actor_profile_migration_state_singleton")), + sa.CheckConstraint( + "(classified_count=0 and manifest_sha256 is null and envelope_sha256 is null) or " + "(classified_count>0 and manifest_sha256 is not null and envelope_sha256 is not null)", + name=op.f("ck_actor_profile_migration_state_evidence"), + ), + ) + bind.execute( + sa.text( + "insert into actor_profile_migration_state " + "(id,schema_version,classified_count,source_row_set_sha256,manifest_sha256,envelope_sha256) " + "values (1,1,:count,:source,:manifest,:envelope)" + ), + { + "count": len(rows), + "source": envelope.source_row_set_sha256 if envelope else source_row_set_sha256(()), + "manifest": envelope.manifest_sha256 if envelope else None, + "envelope": envelope.envelope_sha256 if envelope else None, + }, + ) + legacy = bind.execute( + sa.text( + "select actor_id, external_subject, external_issuer, " + "first_seen_at, last_seen_at, updated_at from legacy_actor_identities order by actor_id" + ) + ).all() + for row in legacy: + kind = kinds[row.actor_id] + profile_method = "automatic_first_access" if kind == "human" else "manual_service_provisioning" + creator = row.actor_id if kind == "human" else "workstream:system:legacy-migration" + link_id = str(uuid5(NAMESPACE_URL, f"workstream:identity-link:{row.external_issuer}:{row.external_subject}")) + bind.execute( + sa.text( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,display_name,contact_email,created_by,created_at,updated_at,last_seen_at) " + "values (:id,:kind,'active',:method,null,null,:creator,:created,:updated,:last_seen)" + ), + { + "id": row.actor_id, + "kind": kind, + "method": profile_method, + "creator": creator, + "created": row.first_seen_at, + "updated": row.updated_at, + "last_seen": row.last_seen_at, + }, + ) + bind.execute( + sa.text( + "insert into actor_identity_links " + "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by,linked_at,last_verified_at) " + "values (:id,:profile,:issuer,:subject,:kind,'active',:linked_by,:linked_at,:verified_at)" + ), + { + "id": link_id, + "profile": row.actor_id, + "issuer": row.external_issuer, + "subject": row.external_subject, + "kind": kind, + "linked_by": creator, + "linked_at": row.first_seen_at, + "verified_at": row.last_seen_at, + }, + ) + _install_guards() + + +def downgrade() -> None: + """Restore the legacy registry using only durable database state.""" + bind = op.get_bind() + bind.execute(sa.text("lock table actor_profiles in access exclusive mode")) + bind.execute(sa.text("lock table actor_identity_links in access exclusive mode")) + bind.execute(sa.text("lock table legacy_actor_identities in access exclusive mode")) + unsafe_state = bind.execute( + sa.text( + "select exists(" + "select 1 from actor_profiles p join actor_identity_links l " + "on l.actor_profile_id=p.id " + "where p.status <> 'active' or l.status <> 'active'" + ")" + ) + ).scalar_one() + if unsafe_state: + raise RuntimeError("canonical actor downgrade refused: inactive authority state") + bind.execute( + sa.text( + "insert into legacy_actor_identities " + "(actor_id,external_subject,external_issuer,display_name,email,last_seen_roles,last_claim_snapshot,auth_source,is_dev_auth,first_seen_at,last_seen_at,updated_at) " + "select p.id,l.subject,l.issuer,p.display_name,p.contact_email,'[]'::json,'{}'::json,'flow',false,p.created_at,coalesce(p.last_seen_at,p.created_at),p.updated_at " + "from actor_profiles p join actor_identity_links l on l.actor_profile_id=p.id " + "on conflict (actor_id) do update set " + "last_seen_at=excluded.last_seen_at,updated_at=excluded.updated_at" + ) + ) + op.execute("drop trigger actor_identity_link_profile_guard on actor_identity_links") + op.execute("drop trigger actor_profile_link_guard on actor_profiles") + op.execute("drop function validate_canonical_actor_link()") + op.execute("drop trigger actor_identity_link_history_guard on actor_identity_links") + op.execute("drop function guard_actor_identity_link_history()") + op.execute("drop trigger actor_profile_history_guard on actor_profiles") + op.execute("drop function guard_actor_profile_history()") + op.drop_table("actor_identity_links") + op.drop_table("actor_profiles") + op.drop_table("actor_profile_migration_state") + + statements = ( + "alter table legacy_actor_identities rename constraint pk_legacy_actor_identities to pk_actor_identities", + "alter table legacy_actor_identities rename constraint uq_legacy_actor_identities_external_identity to uq_actor_identities_external_identity", + "alter table legacy_workflow_eligibility rename constraint pk_legacy_workflow_eligibility to pk_actor_profiles", + "alter table legacy_workflow_eligibility rename constraint ck_legacy_workflow_eligibility_profile_type to ck_actor_profiles_ck_actor_profiles_profile_type", + "alter table legacy_workflow_eligibility rename constraint ck_legacy_workflow_eligibility_status to ck_actor_profiles_ck_actor_profiles_status", + "alter table legacy_workflow_eligibility rename constraint uq_legacy_workflow_eligibility_actor_type_scope to uq_actor_profiles_actor_type_scope", + "alter table legacy_workflow_eligibility rename constraint fk_legacy_workflow_eligibility_actor_id_legacy_actor_identities to fk_actor_profiles_actor_id_actor_identities", + "alter index ix_legacy_workflow_eligibility_actor_id rename to ix_actor_profiles_actor_id", + "alter index ix_legacy_workflow_eligibility_profile_type rename to ix_actor_profiles_profile_type", + "alter index ix_legacy_workflow_eligibility_status rename to ix_actor_profiles_status", + ) + for statement in statements: + op.execute(statement) + op.rename_table("legacy_actor_identities", "actor_identities") + op.rename_table("legacy_workflow_eligibility", "actor_profiles") diff --git a/backend/app/adapters/auth/dev.py b/backend/app/adapters/auth/dev.py index 35458d28..31100fa4 100644 --- a/backend/app/adapters/auth/dev.py +++ b/backend/app/adapters/auth/dev.py @@ -9,6 +9,7 @@ from app.schemas.auth import ( AuthVerificationResult, LegacyAuthorizationCompatibilityContext, + MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS, VerifiedIssuerToken, actor_id_from_external_identity, normalize_legacy_roles, @@ -34,9 +35,15 @@ def __init__(self, settings: Settings) -> None: raise RuntimeError("development auth cannot run in production") if not settings.dev_auth_token: raise RuntimeError("WORKSTREAM_DEV_AUTH_TOKEN must be set for development auth") - if not settings.dev_auth_subject: + if ( + not settings.dev_auth_subject + or len(settings.dev_auth_subject) > MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS + ): raise RuntimeError("WORKSTREAM_DEV_AUTH_SUBJECT must be set for development auth") - if not settings.dev_auth_issuer: + if ( + not settings.dev_auth_issuer + or len(settings.dev_auth_issuer) > MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS + ): raise RuntimeError("WORKSTREAM_DEV_AUTH_ISSUER must be set for development auth") self._settings = settings diff --git a/backend/app/adapters/auth/flow.py b/backend/app/adapters/auth/flow.py index 9c973118..91b552d6 100644 --- a/backend/app/adapters/auth/flow.py +++ b/backend/app/adapters/auth/flow.py @@ -29,6 +29,7 @@ from app.schemas.auth import ( AuthVerificationResult, LegacyAuthorizationCompatibilityContext, + MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS, VerifiedIssuerToken, actor_id_from_external_identity, normalize_legacy_roles, @@ -176,6 +177,8 @@ def __init__( if self._local_hmac_secret: self._issuer = settings.flow_auth_issuer + if not self._issuer or len(self._issuer) > MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS: + raise RuntimeError("WORKSTREAM_FLOW_AUTH_ISSUER is invalid") self._audience = settings.flow_auth_audience self._algorithms = ("HS256",) self._jwks_url = None @@ -184,6 +187,8 @@ def __init__( return self._issuer = _canonical_https_url(settings.token_issuer, name="WORKSTREAM_TOKEN_ISSUER") + if len(self._issuer) > MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS: + raise RuntimeError("WORKSTREAM_TOKEN_ISSUER is invalid") if urlsplit(self._issuer).hostname == "auth.flow.local": raise RuntimeError("WORKSTREAM_TOKEN_ISSUER cannot use the placeholder issuer") self._jwks_url = _canonical_https_url( @@ -394,7 +399,11 @@ def _result_from_verified_claims( subject = claims.get("sub") token_id = claims.get("jti") subject_kind = claims.get("subject_kind") - if not isinstance(subject, str) or not subject or len(subject) > 512: + if ( + not isinstance(subject, str) + or not subject + or len(subject) > MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS + ): raise AuthVerificationError("token subject is required") if not isinstance(token_id, str) or not token_id or len(token_id) > 512: raise AuthVerificationError("token identifier is required") diff --git a/backend/app/api/deps/api_controls.py b/backend/app/api/deps/api_controls.py index 62af6f70..c4037ef6 100644 --- a/backend/app/api/deps/api_controls.py +++ b/backend/app/api/deps/api_controls.py @@ -4,65 +4,18 @@ from typing import Annotated -from fastapi import Depends, Request, status +from fastapi import Depends, Request from app.api.deps.auth import get_auth_verification_result -from app.core.api_controls import StructuredHTTPException +from app.api.deps.rate_controls import enforce_rate_control, get_rate_control_service from app.modules.api_controls.service import ( ADMIN_MUTATION_SCOPE, FIRST_ACCESS_SCOPE, RateControlService, - RateControlUnavailableError, ) from app.schemas.auth import AuthVerificationResult -def get_rate_control_service() -> RateControlService: - """Return a limiter whose consumption owns a dedicated database session.""" - return RateControlService() - - -def _unavailable() -> StructuredHTTPException: - return StructuredHTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Service unavailable", - error_code="service_unavailable", - error_message="Service unavailable", - retryable=True, - ) - - -async def _enforce( - *, - request: Request, - result: AuthVerificationResult, - service: RateControlService, - control_scope: str, - limit: int, - window_seconds: int, -) -> None: - try: - decision = await service.consume( - control_scope=control_scope, - issuer=result.token.issuer, - subject=result.token.subject, - limit=limit, - window_seconds=window_seconds, - secret=request.app.state.settings.api_rate_limit_key_secret, - ) - except RateControlUnavailableError as exc: - raise _unavailable() from exc - if not decision.allowed: - raise StructuredHTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="Rate limit exceeded", - error_code="rate_limit_exceeded", - error_message="Rate limit exceeded", - retryable=True, - headers={"Retry-After": str(decision.retry_after)}, - ) - - async def enforce_first_access_rate_limit( request: Request, result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], @@ -70,7 +23,7 @@ async def enforce_first_access_rate_limit( ) -> None: """Consume the future first-access mutation allowance.""" settings = request.app.state.settings - await _enforce( + await enforce_rate_control( request=request, result=result, service=service, @@ -87,7 +40,7 @@ async def enforce_admin_mutation_rate_limit( ) -> None: """Consume the future authority-management mutation allowance.""" settings = request.app.state.settings - await _enforce( + await enforce_rate_control( request=request, result=result, service=service, diff --git a/backend/app/api/deps/auth.py b/backend/app/api/deps/auth.py index 1901b325..ce265f51 100644 --- a/backend/app/api/deps/auth.py +++ b/backend/app/api/deps/auth.py @@ -3,13 +3,19 @@ from __future__ import annotations from typing import Annotated +from uuid import UUID from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession -from app.core.api_controls import StructuredHTTPException +from app.core.api_controls import StructuredHTTPException, request_ids +from app.api.deps.rate_controls import ( + enforce_rate_control, + get_rate_control_service, + service_unavailable_error, +) from app.core.auth import get_auth_verifier from app.db.session import get_db_session from app.interfaces.auth import ( @@ -17,7 +23,18 @@ AuthVerificationUnavailableError, AuthVerifier, ) -from app.modules.actors.service import ActorRegistryError, ActorService +from app.modules.actors.service import ( + ActorDeactivated, + ActorRegistryError, + ActorService, + ActorSuspended, + ResolvedActor, + UnsupportedSubjectKind, +) +from app.modules.api_controls.service import ( + FIRST_ACCESS_SCOPE, + RateControlService, +) from app.schemas.auth import ActorContext, AuthVerificationResult, VerifiedIssuerToken bearer_scheme = HTTPBearer(auto_error=False) @@ -88,20 +105,63 @@ async def get_current_actor( return result.token -async def get_registered_actor( +def actor_registry_http_error(exc: ActorRegistryError) -> StructuredHTTPException: + """Map actor resolution to the stable structured API envelope.""" + return StructuredHTTPException( + status_code=exc.status_code, + detail=str(exc), + error_code=exc.code, + error_message=str(exc), + ) + + +def actor_registry_unavailable_error() -> StructuredHTTPException: + """Build the canonical retryable actor-registry unavailable response.""" + return service_unavailable_error("Actor registry unavailable") + + +async def get_canonical_actor( + request: Request, result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], session: Annotated[AsyncSession, Depends(get_db_session)], -) -> ActorContext: - """Resolve the current actor and refresh local registry metadata. + rate_control: Annotated[RateControlService, Depends(get_rate_control_service)], +) -> ResolvedActor: + """Resolve one verified token to the canonical local actor.""" + if result.token.subject_kind not in {"human", "service"}: + raise actor_registry_http_error(UnsupportedSubjectKind("Unsupported subject kind")) + service = ActorService(session) + try: + existing = await service.find_verified_actor(result.token) + if existing is None and result.token.subject_kind == "human": + settings = request.app.state.settings + await enforce_rate_control( + request=request, + result=result, + service=rate_control, + control_scope=FIRST_ACCESS_SCOPE, + limit=settings.api_first_access_rate_limit, + window_seconds=settings.api_first_access_rate_window_seconds, + ) + request_id, correlation_id = request_ids(request) + return await service.resolve_verified_actor( + result.token, + request_id=UUID(request_id), + correlation_id=UUID(correlation_id), + ) + except ActorRegistryError as exc: + await session.rollback() + raise actor_registry_http_error(exc) from exc + except SQLAlchemyError as exc: + await session.rollback() + raise actor_registry_unavailable_error() from exc - Args: - result: Verified token result from the pure auth boundary. - session: Database session for registry persistence. - Returns: - The same verified actor context. Route authorization must still use - this token-derived context, not persisted profile rows. - """ +async def get_registered_actor( + result: Annotated[AuthVerificationResult, Depends(get_auth_verification_result)], + resolved: Annotated[ResolvedActor, Depends(get_canonical_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ActorContext: + """Return the bounded legacy context after canonical actor resolution.""" if result.token.subject_kind != "human" or result.legacy is None: raise StructuredHTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -111,17 +171,15 @@ async def get_registered_actor( ) actor = result.legacy_actor() try: - await ActorService(session).register_actor(actor) + if resolved.profile.status == "suspended": + raise ActorSuspended("Actor is suspended") + if resolved.profile.status == "deactivated": + raise ActorDeactivated("Actor is deactivated") + await ActorService(session).refresh_legacy_identity(actor) except ActorRegistryError as exc: await session.rollback() - raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc + raise actor_registry_http_error(exc) from exc except SQLAlchemyError as exc: await session.rollback() - raise StructuredHTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Actor registry unavailable", - error_code="service_unavailable", - error_message="Service unavailable", - retryable=True, - ) from exc + raise actor_registry_unavailable_error() from exc return actor diff --git a/backend/app/api/deps/rate_controls.py b/backend/app/api/deps/rate_controls.py new file mode 100644 index 00000000..388b38be --- /dev/null +++ b/backend/app/api/deps/rate_controls.py @@ -0,0 +1,58 @@ +"""Shared FastAPI rate-control dependency and error mapping.""" + +from fastapi import Request, status + +from app.core.api_controls import StructuredHTTPException +from app.modules.api_controls.service import ( + RateControlService, + RateControlUnavailableError, +) +from app.schemas.auth import AuthVerificationResult + + +def get_rate_control_service() -> RateControlService: + """Return a limiter whose consumption owns a dedicated database session.""" + return RateControlService() + + +def service_unavailable_error(detail: str = "Service unavailable") -> StructuredHTTPException: + """Build the canonical retryable service-unavailable response.""" + return StructuredHTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=detail, + error_code="service_unavailable", + error_message="Service unavailable", + retryable=True, + ) + + +async def enforce_rate_control( + *, + request: Request, + result: AuthVerificationResult, + service: RateControlService, + control_scope: str, + limit: int, + window_seconds: int, +) -> None: + """Consume one configured allowance and map bounded HTTP failures.""" + try: + decision = await service.consume( + control_scope=control_scope, + issuer=result.token.issuer, + subject=result.token.subject, + limit=limit, + window_seconds=window_seconds, + secret=request.app.state.settings.api_rate_limit_key_secret, + ) + except RateControlUnavailableError as exc: + raise service_unavailable_error() from exc + if not decision.allowed: + raise StructuredHTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Rate limit exceeded", + error_code="rate_limit_exceeded", + error_message="Rate limit exceeded", + retryable=True, + headers={"Retry-After": str(decision.retry_after)}, + ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 0003af9c..deb27b02 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -4,7 +4,7 @@ from fastapi import APIRouter -from app.api.routes.auth import router as auth_router +from app.api.routes.auth import actors_router, router as auth_router from app.api.routes.health import router as health_router from app.modules.checkers.router import router as checkers_router from app.modules.projects.router import router as projects_router @@ -14,6 +14,7 @@ api_router.include_router(health_router) api_router.include_router(health_router, prefix="/api/v1") api_router.include_router(auth_router, prefix="/api/v1") +api_router.include_router(actors_router, prefix="/api/v1") api_router.include_router(projects_router, prefix="/api/v1") api_router.include_router(tasks_router, prefix="/api/v1") api_router.include_router(checkers_router, prefix="/api/v1") diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index d6f1899f..27ffa17c 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -5,11 +5,22 @@ from typing import Annotated from fastapi import APIRouter, Depends +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps.auth import get_registered_actor +from app.api.deps.auth import ( + actor_registry_http_error, + actor_registry_unavailable_error, + get_canonical_actor, + get_registered_actor, +) +from app.db.session import get_db_session +from app.modules.actors.schemas import ActorProfileSelfResponse, ActorProfileUpdateRequest +from app.modules.actors.service import ActorRegistryError, ActorService, ResolvedActor from app.schemas.auth import ActorContext, ActorResponse router = APIRouter(prefix="/auth", tags=["auth"]) +actors_router = APIRouter(prefix="/actors", tags=["actors"]) @router.get("/me", response_model=ActorResponse) @@ -25,3 +36,31 @@ async def read_current_actor( Public actor response including audit context. """ return ActorResponse.from_actor(actor) + + +@actors_router.get("/me", response_model=ActorProfileSelfResponse) +async def read_current_actor_profile( + resolved: Annotated[ResolvedActor, Depends(get_canonical_actor)], +) -> ActorProfileSelfResponse: + """Return the caller's canonical Contributor-domain profile.""" + try: + return ActorService.self_response(resolved.profile) + except ActorRegistryError as exc: + raise actor_registry_http_error(exc) from exc + + +@actors_router.patch("/me", response_model=ActorProfileSelfResponse) +async def update_current_actor_profile( + payload: ActorProfileUpdateRequest, + resolved: Annotated[ResolvedActor, Depends(get_canonical_actor)], + session: Annotated[AsyncSession, Depends(get_db_session)], +) -> ActorProfileSelfResponse: + """Update only the caller-owned canonical display fields.""" + try: + return await ActorService(session).update_self(resolved, payload) + except ActorRegistryError as exc: + await session.rollback() + raise actor_registry_http_error(exc) from exc + except SQLAlchemyError as exc: + await session.rollback() + raise actor_registry_unavailable_error() from exc diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 827d171a..46b5d75d 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -1,6 +1,11 @@ """Import domain models so Alembic can discover metadata.""" -from app.modules.actors.models import ActorIdentity, ActorProfile # noqa: F401 +from app.modules.actors.models import ( # noqa: F401 + ActorIdentityLink, + ActorProfile, + LegacyActorIdentity, + LegacyWorkflowEligibility, +) from app.modules.api_controls.models import ApiRateControlCounter # noqa: F401 from app.modules.artifacts.models import ( # noqa: F401 ArtifactBinding, diff --git a/backend/app/modules/actors/models.py b/backend/app/modules/actors/models.py index 060ad39e..a9094cdd 100644 --- a/backend/app/modules/actors/models.py +++ b/backend/app/modules/actors/models.py @@ -1,32 +1,152 @@ -"""SQLAlchemy models for verified actor identity and profile metadata.""" +"""Canonical actor identity models and bounded legacy workflow metadata.""" from __future__ import annotations from datetime import datetime -from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, JSON, String, UniqueConstraint +from sqlalchemy import ( + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + Index, + JSON, + String, + UniqueConstraint, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func from app.db.base import Base -ACTOR_PROFILE_TYPES = ("worker", "reviewer", "admin", "project_manager", "project_owner") -ACTOR_PROFILE_STATUSES = ("observed", "active", "disabled") +ACTOR_KINDS = ("human", "service") +ACTOR_PROFILE_STATUSES = ("active", "suspended", "deactivated") +ACTOR_PROVISIONING_METHODS = ("automatic_first_access", "manual_service_provisioning") +IDENTITY_LINK_STATUSES = ("active", "revoked") +LEGACY_PROFILE_TYPES = ("worker", "reviewer", "admin", "project_manager", "project_owner") +LEGACY_PROFILE_STATUSES = ("observed", "active", "disabled") GLOBAL_PROFILE_SCOPE_TYPE = "global" GLOBAL_PROFILE_SCOPE_ID = "global" -_PROFILE_TYPE_CHECK_VALUES = ", ".join(f"'{profile_type}'" for profile_type in ACTOR_PROFILE_TYPES) -_PROFILE_STATUS_CHECK_VALUES = ", ".join(f"'{status}'" for status in ACTOR_PROFILE_STATUSES) -class ActorIdentity(Base): - """Local registry row for a verified external Flow actor.""" +def _sql_values(values: tuple[str, ...]) -> str: + return ", ".join(f"'{value}'" for value in values) - __tablename__ = "actor_identities" + +class ActorProfile(Base): + """Canonical Workstream actor root without implied product authority.""" + + __tablename__ = "actor_profiles" + __table_args__ = ( + CheckConstraint(f"actor_kind in ({_sql_values(ACTOR_KINDS)})", name="actor_kind"), + CheckConstraint( + f"status in ({_sql_values(ACTOR_PROFILE_STATUSES)})", name="status" + ), + CheckConstraint( + f"provisioning_method in ({_sql_values(ACTOR_PROVISIONING_METHODS)})", + name="provisioning_method", + ), + CheckConstraint( + "(actor_kind = 'human' and provisioning_method = 'automatic_first_access') or " + "(actor_kind = 'service' and provisioning_method = 'manual_service_provisioning')", + name="kind_provisioning", + ), + CheckConstraint( + "(status = 'active' and suspended_by is null and suspended_at is null and " + "suspension_reason is null and deactivated_by is null and deactivated_at is null " + "and deactivation_reason is null) or " + "(status = 'suspended' and suspended_by is not null and suspended_at is not null " + "and suspension_reason is not null and deactivated_by is null and " + "deactivated_at is null and deactivation_reason is null) or " + "(status = 'deactivated' and deactivated_by is not null and deactivated_at is not null " + "and deactivation_reason is not null)", + name="lifecycle_fields", + ), + Index("ix_actor_profiles_status_actor_kind", "status", "actor_kind"), + Index("ix_actor_profiles_last_seen_at", "last_seen_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + actor_kind: Mapped[str] = mapped_column(String(16), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") + provisioning_method: Mapped[str] = mapped_column(String(32), nullable=False) + display_name: Mapped[str | None] = mapped_column(String(200)) + contact_email: Mapped[str | None] = mapped_column(String(320)) + created_by: Mapped[str] = mapped_column(String(120), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + suspended_by: Mapped[str | None] = mapped_column(String(120)) + suspended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + suspension_reason: Mapped[str | None] = mapped_column(String(500)) + deactivated_by: Mapped[str | None] = mapped_column(String(120)) + deactivated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + deactivation_reason: Mapped[str | None] = mapped_column(String(500)) + + identity_link: Mapped[ActorIdentityLink] = relationship( + back_populates="actor_profile", + uselist=False, + ) + + +class ActorIdentityLink(Base): + """One stable external issuer subject linked to one canonical actor.""" + + __tablename__ = "actor_identity_links" + __table_args__ = ( + CheckConstraint(f"subject_kind in ({_sql_values(ACTOR_KINDS)})", name="subject_kind"), + CheckConstraint( + f"status in ({_sql_values(IDENTITY_LINK_STATUSES)})", name="status" + ), + CheckConstraint( + "(status = 'active' and revoked_by is null and revoked_at is null and " + "revoked_reason is null) or " + "(status = 'revoked' and revoked_by is not null and revoked_at is not null and " + "revoked_reason is not null)", + name="revocation_fields", + ), + UniqueConstraint("issuer", "subject", name="external_identity"), + UniqueConstraint("actor_profile_id", name="actor_profile"), + Index( + "ix_actor_identity_links_issuer_subject_status", + "issuer", + "subject", + "status", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + actor_profile_id: Mapped[str] = mapped_column( + ForeignKey("actor_profiles.id"), nullable=False + ) + issuer: Mapped[str] = mapped_column(String(200), nullable=False) + subject: Mapped[str] = mapped_column(String(200), nullable=False) + subject_kind: Mapped[str] = mapped_column(String(16), nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") + linked_by: Mapped[str] = mapped_column(String(120), nullable=False) + linked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + last_verified_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + revoked_by: Mapped[str | None] = mapped_column(String(120)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_reason: Mapped[str | None] = mapped_column(String(500)) + reactivated_by: Mapped[str | None] = mapped_column(String(120)) + reactivated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + reactivation_reason: Mapped[str | None] = mapped_column(String(500)) + + actor_profile: Mapped[ActorProfile] = relationship(back_populates="identity_link") + + +class LegacyActorIdentity(Base): + """Non-authoritative token-observation row retained for intermediate workflows.""" + + __tablename__ = "legacy_actor_identities" __table_args__ = ( UniqueConstraint( "external_issuer", "external_subject", - name="uq_actor_identities_external_identity", + name="uq_legacy_actor_identities_external_identity", ), ) @@ -41,45 +161,39 @@ class ActorIdentity(Base): is_dev_auth: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - ) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - profiles: Mapped[list[ActorProfile]] = relationship( + workflow_eligibility: Mapped[list[LegacyWorkflowEligibility]] = relationship( back_populates="identity", cascade="all, delete-orphan", ) -class ActorProfile(Base): - """Shared workflow metadata and eligibility profile for an actor.""" +class LegacyWorkflowEligibility(Base): + """Classified workflow metadata that grants no Workstream permission.""" - __tablename__ = "actor_profiles" + __tablename__ = "legacy_workflow_eligibility" __table_args__ = ( CheckConstraint( - f"profile_type in ({_PROFILE_TYPE_CHECK_VALUES})", - name="ck_actor_profiles_profile_type", + f"profile_type in ({_sql_values(LEGACY_PROFILE_TYPES)})", + name="profile_type", ), CheckConstraint( - f"status in ({_PROFILE_STATUS_CHECK_VALUES})", - name="ck_actor_profiles_status", + f"status in ({_sql_values(LEGACY_PROFILE_STATUSES)})", + name="status", ), UniqueConstraint( "actor_id", "profile_type", "scope_type", "scope_id", - name="uq_actor_profiles_actor_type_scope", + name="actor_type_scope", ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True) actor_id: Mapped[str] = mapped_column( - ForeignKey("actor_identities.actor_id"), - nullable=False, - index=True, + ForeignKey("legacy_actor_identities.actor_id"), nullable=False, index=True ) profile_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) status: Mapped[str] = mapped_column(String(30), nullable=False, default="observed", index=True) @@ -88,10 +202,6 @@ class ActorProfile(Base): scope_id: Mapped[str] = mapped_column(String(100), nullable=False, default=GLOBAL_PROFILE_SCOPE_ID) profile_metadata: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - ) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - identity: Mapped[ActorIdentity] = relationship(back_populates="profiles") + identity: Mapped[LegacyActorIdentity] = relationship(back_populates="workflow_eligibility") diff --git a/backend/app/modules/actors/repository.py b/backend/app/modules/actors/repository.py index 03cff595..1c85a4b6 100644 --- a/backend/app/modules/actors/repository.py +++ b/backend/app/modules/actors/repository.py @@ -1,60 +1,109 @@ -"""Database access methods for actor identities and profiles.""" +"""Persistence for canonical actors and bounded legacy workflow metadata.""" from __future__ import annotations -from collections.abc import Sequence +import hashlib from sqlalchemy import func, select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.actors.models import ActorIdentity, ActorProfile +from app.modules.actors.models import ( + ActorIdentityLink, + ActorProfile, + LegacyActorIdentity, + LegacyWorkflowEligibility, +) class ActorRepository: - """Wraps SQLAlchemy persistence for actor registry operations.""" + """Wrap canonical actor persistence in the caller-owned transaction.""" def __init__(self, session: AsyncSession) -> None: - """Create a repository bound to one database session. - - Args: - session: Async SQLAlchemy session for the current unit of work. - """ self._session = session - async def get_identity( + async def lock_external_identity(self, issuer: str, subject: str) -> None: + """Serialize first access for one exact external identity.""" + digest = hashlib.sha256( + len(issuer.encode()).to_bytes(4, "big") + + issuer.encode() + + len(subject.encode()).to_bytes(4, "big") + + subject.encode() + ).digest() + key = int.from_bytes(digest[:8], "big", signed=True) + await self._session.execute(select(func.pg_advisory_xact_lock(key))) + + async def get_identity_link( self, - actor_id: str, + issuer: str, + subject: str, *, - populate_existing: bool = False, - ) -> ActorIdentity | None: - """Load an actor identity by stable Workstream actor id. + for_update: bool = False, + ) -> ActorIdentityLink | None: + """Load the immutable link for one issuer and opaque subject.""" + query = select(ActorIdentityLink).where( + ActorIdentityLink.issuer == issuer, + ActorIdentityLink.subject == subject, + ) + if for_update: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def get_actor_profile( + self, + actor_profile_id: str, + *, + for_update: bool = False, + ) -> ActorProfile | None: + """Load one canonical actor profile.""" + query = select(ActorProfile).where(ActorProfile.id == actor_profile_id) + if for_update: + query = query.with_for_update() + return await self._session.scalar(query.execution_options(populate_existing=True)) + + async def add_actor_profile(self, profile: ActorProfile) -> ActorProfile: + """Stage one canonical actor profile.""" + self._session.add(profile) + await self._session.flush() + return profile + + async def add_identity_link(self, link: ActorIdentityLink) -> ActorIdentityLink: + """Stage one canonical identity link.""" + self._session.add(link) + await self._session.flush() + return link - Args: - actor_id: Stable actor id. - populate_existing: Whether to overwrite any existing session state - with the current database row. + async def touch_verified_actor( + self, + profile: ActorProfile, + link: ActorIdentityLink, + ) -> None: + """Record database-time verification without accepting provider metadata.""" + profile.last_seen_at = func.now() + profile.updated_at = func.now() + link.last_verified_at = func.now() + await self._session.flush() - Returns: - Actor identity when present; otherwise ``None``. - """ + async def get_legacy_identity( + self, + actor_id: str, + *, + populate_existing: bool = False, + ) -> LegacyActorIdentity | None: + """Load non-authoritative legacy token-observation metadata.""" return await self._session.get( - ActorIdentity, + LegacyActorIdentity, actor_id, populate_existing=populate_existing, ) - async def upsert_identity(self, identity: ActorIdentity) -> ActorIdentity: - """Create or refresh an actor identity from trusted token claims. - - Args: - identity: Actor identity carrying the latest trusted claim snapshot. - - Returns: - Persisted actor identity. - """ + async def upsert_legacy_identity( + self, + identity: LegacyActorIdentity, + ) -> LegacyActorIdentity: + """Refresh bounded compatibility metadata without touching canonical actors.""" await self._session.execute( - insert(ActorIdentity) + insert(LegacyActorIdentity) .values( actor_id=identity.actor_id, external_subject=identity.external_subject, @@ -67,12 +116,10 @@ async def upsert_identity(self, identity: ActorIdentity) -> ActorIdentity: is_dev_auth=identity.is_dev_auth, ) .on_conflict_do_update( - index_elements=[ActorIdentity.actor_id], + index_elements=[LegacyActorIdentity.actor_id], set_={ "external_subject": identity.external_subject, "external_issuer": identity.external_issuer, - "display_name": identity.display_name, - "email": identity.email, "last_seen_roles": identity.last_seen_roles, "last_claim_snapshot": identity.last_claim_snapshot, "auth_source": identity.auth_source, @@ -83,87 +130,56 @@ async def upsert_identity(self, identity: ActorIdentity) -> ActorIdentity: ) ) await self._session.flush() - persisted = await self.get_identity(identity.actor_id, populate_existing=True) + persisted = await self.get_legacy_identity(identity.actor_id, populate_existing=True) if persisted is None: - raise RuntimeError("actor identity upsert did not return a persisted row") + raise RuntimeError("legacy identity upsert did not return a row") return persisted - async def get_profile( + async def get_legacy_eligibility( self, actor_id: str, profile_type: str, scope_type: str, scope_id: str, - ) -> ActorProfile | None: - """Load one actor profile by actor, type, and scope. - - Args: - actor_id: Stable actor id. - profile_type: Profile type such as ``worker`` or ``project_manager``. - scope_type: Scope namespace, usually ``global``. - scope_id: Scope identifier, usually ``global``. - - Returns: - Actor profile when present; otherwise ``None``. - """ - result = await self._session.execute( - select(ActorProfile) + ) -> LegacyWorkflowEligibility | None: + """Load one classified legacy workflow-eligibility row.""" + return await self._session.scalar( + select(LegacyWorkflowEligibility) .where( - ActorProfile.actor_id == actor_id, - ActorProfile.profile_type == profile_type, - ActorProfile.scope_type == scope_type, - ActorProfile.scope_id == scope_id, + LegacyWorkflowEligibility.actor_id == actor_id, + LegacyWorkflowEligibility.profile_type == profile_type, + LegacyWorkflowEligibility.scope_type == scope_type, + LegacyWorkflowEligibility.scope_id == scope_id, ) .execution_options(populate_existing=True) ) - return result.scalar_one_or_none() - async def list_profiles(self, actor_id: str) -> Sequence[ActorProfile]: - """List profiles for one actor. - - Args: - actor_id: Stable actor id. - - Returns: - Actor profiles ordered by type and scope. - """ - result = await self._session.execute( - select(ActorProfile) - .where(ActorProfile.actor_id == actor_id) - .order_by(ActorProfile.profile_type.asc(), ActorProfile.scope_type.asc()) - ) - return result.scalars().all() - - async def insert_profile_if_absent(self, profile: ActorProfile) -> bool: - """Insert a profile without overwriting existing scoped profile state. - - Args: - profile: Actor profile to insert when absent. - - Returns: - True when this call inserted the profile; false on conflict. - """ + async def insert_legacy_eligibility_if_absent( + self, + eligibility: LegacyWorkflowEligibility, + ) -> bool: + """Insert compatibility metadata without overwriting established state.""" result = await self._session.execute( - insert(ActorProfile) + insert(LegacyWorkflowEligibility) .values( - id=profile.id, - actor_id=profile.actor_id, - profile_type=profile.profile_type, - status=profile.status, - skill_tags=profile.skill_tags, - scope_type=profile.scope_type, - scope_id=profile.scope_id, - profile_metadata=profile.profile_metadata, + id=eligibility.id, + actor_id=eligibility.actor_id, + profile_type=eligibility.profile_type, + status=eligibility.status, + skill_tags=eligibility.skill_tags, + scope_type=eligibility.scope_type, + scope_id=eligibility.scope_id, + profile_metadata=eligibility.profile_metadata, ) .on_conflict_do_nothing( index_elements=[ - ActorProfile.actor_id, - ActorProfile.profile_type, - ActorProfile.scope_type, - ActorProfile.scope_id, + LegacyWorkflowEligibility.actor_id, + LegacyWorkflowEligibility.profile_type, + LegacyWorkflowEligibility.scope_type, + LegacyWorkflowEligibility.scope_id, ] ) - .returning(ActorProfile.id) + .returning(LegacyWorkflowEligibility.id) ) await self._session.flush() return result.scalar_one_or_none() is not None diff --git a/backend/app/modules/actors/schemas.py b/backend/app/modules/actors/schemas.py index b8b867d1..334a95ab 100644 --- a/backend/app/modules/actors/schemas.py +++ b/backend/app/modules/actors/schemas.py @@ -1,40 +1,74 @@ -"""Pydantic schemas for actor profile APIs.""" +"""Strict schemas for canonical actor self-service and legacy eligibility.""" from __future__ import annotations from datetime import datetime +from typing import Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator def normalize_skill_tags(value: list[str]) -> list[str]: - """Normalize actor skill tags to stable lowercase tokens. - - Args: - value: Client or service supplied skill tags. - - Returns: - Deduplicated normalized skill tags. - - Raises: - ValueError: If any tag is empty or too long. - """ + """Return stable deduplicated legacy workflow skill tags.""" normalized_tags: list[str] = [] seen_tags: set[str] = set() for raw_tag in value: tag = raw_tag.strip().lower() - if not tag: - raise ValueError("skill_tags cannot include empty values") - if len(tag) > 64: - raise ValueError("skill_tags values must be 64 characters or fewer") + if not tag or len(tag) > 64: + raise ValueError("invalid skill tag") if tag not in seen_tags: normalized_tags.append(tag) seen_tags.add(tag) return normalized_tags -class ActorProfileActivationRequest(BaseModel): - """Request schema for explicitly activating the current actor's profile.""" +class ActorProfileUpdateRequest(BaseModel): + """Human-owned display fields accepted by the canonical self API.""" + + model_config = ConfigDict(extra="forbid") + + display_name: str | None = Field(default=None, max_length=200) + contact_email: str | None = Field(default=None, max_length=320) + + @field_validator("display_name", "contact_email") + @classmethod + def normalize_optional_text(cls, value: str | None) -> str | None: + """Reject whitespace-only values while preserving opaque text.""" + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("profile field must not be blank") + return normalized + + @model_validator(mode="after") + def require_update(self): + """Reject an empty PATCH document.""" + if not self.model_fields_set: + raise ValueError("at least one profile field is required") + return self + + +class ActorProfileSelfResponse(BaseModel): + """Privacy-bounded canonical profile returned to its human owner.""" + + model_config = ConfigDict(extra="forbid", from_attributes=True) + + actor_profile_id: str + actor_kind: Literal["human"] + status: Literal["active", "suspended", "deactivated"] + domains: tuple[Literal["contributor"], ...] = ("contributor",) + admin_roles: tuple[str, ...] = () + project_role_grants: tuple[str, ...] = () + display_name: str | None + contact_email: str | None + created_at: datetime + updated_at: datetime + last_seen_at: datetime | None + + +class LegacyWorkflowEligibilityActivationRequest(BaseModel): + """Temporary non-authoritative intake metadata for existing task workflows.""" model_config = ConfigDict(extra="forbid") @@ -43,14 +77,13 @@ class ActorProfileActivationRequest(BaseModel): @field_validator("skill_tags") @classmethod def validate_skill_tags(cls, value: list[str]) -> list[str]: - """Normalize profile skill tags before persistence.""" return normalize_skill_tags(value) -class ActorProfileResponse(BaseModel): - """Response schema for a profile joined to token-derived identity metadata.""" +class LegacyWorkflowEligibilityResponse(BaseModel): + """Temporary compatibility response that grants no product permission.""" - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(extra="forbid", from_attributes=True) id: str actor_id: str @@ -62,7 +95,5 @@ class ActorProfileResponse(BaseModel): profile_metadata: dict external_subject: str external_issuer: str - display_name: str | None - email: str | None created_at: datetime updated_at: datetime diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index de9c420d..cd89b144 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -1,467 +1,388 @@ -"""Service layer for actor identity registration and profile eligibility.""" +"""Canonical actor resolution and bounded legacy workflow compatibility.""" from __future__ import annotations -from collections.abc import Iterable -from datetime import UTC, datetime, timedelta -from uuid import uuid4 +from dataclasses import dataclass +from uuid import UUID, uuid4 from sqlalchemy import func from sqlalchemy.ext.asyncio import AsyncSession -from app.core.config import get_settings from app.core.permissions import require_any_role -from app.modules.audit.repository import AuditRepository from app.modules.actors.models import ( - ACTOR_PROFILE_TYPES, GLOBAL_PROFILE_SCOPE_ID, GLOBAL_PROFILE_SCOPE_TYPE, - ActorIdentity, + ActorIdentityLink, ActorProfile, + LegacyActorIdentity, + LegacyWorkflowEligibility, ) from app.modules.actors.repository import ActorRepository -from app.modules.actors.schemas import ActorProfileActivationRequest, ActorProfileResponse +from app.modules.actors.schemas import ( + ActorProfileSelfResponse, + ActorProfileUpdateRequest, + LegacyWorkflowEligibilityActivationRequest, + LegacyWorkflowEligibilityResponse, +) +from app.modules.audit.repository import AuditRepository +from app.modules.audit.schemas import ( + ActorReferenceKind, + AuthorityAuditEventInput, + AuthorityEventType, +) +from app.modules.audit.service import AuditService from app.modules.tasks.models import AuditEvent -from app.schemas.auth import ActorContext, normalized_relationship_profile_claims, sanitized_claim_snapshot - -TOKEN_OBSERVED_PROFILE_TYPES = {"worker", "reviewer", "admin", "project_manager"} +from app.schemas.auth import ActorContext, VerifiedIssuerToken, actor_id_from_external_identity class ActorRegistryError(Exception): - """Base error for actor registry failures.""" + """Stable actor-resolution failure safe for API translation.""" status_code = 400 + code = "actor_registry_error" + + +class UnsupportedSubjectKind(ActorRegistryError): + status_code = 403 + code = "unsupported_subject_kind" + + +class ServiceActorNotProvisioned(ActorRegistryError): + status_code = 403 + code = "service_actor_not_provisioned" + + +class IdentityLinkRevoked(ActorRegistryError): + status_code = 403 + code = "identity_link_revoked" + + +class ActorSuspended(ActorRegistryError): + status_code = 403 + code = "actor_suspended" + + +class ActorDeactivated(ActorRegistryError): + status_code = 403 + code = "actor_deactivated" class ActorProfileDisabled(ActorRegistryError): - """Raised when an explicit profile workflow tries to use a disabled profile.""" + """Temporary compatibility denial for a disabled eligibility row.""" status_code = 403 + code = "legacy_workflow_eligibility_disabled" + + +@dataclass(frozen=True) +class ResolvedActor: + """Canonical profile and exact verified identity link for one request.""" + + profile: ActorProfile + identity_link: ActorIdentityLink class ActorService: - """Coordinates actor identity and profile registry rules.""" + """Resolve canonical actors and own first-human provisioning transactions.""" def __init__(self, session: AsyncSession) -> None: - """Create a service instance bound to one database session. - - Args: - session: Async SQLAlchemy session for the current request. - """ self._session = session self._repo = ActorRepository(session) - self._audit_repo = AuditRepository(session) - - async def register_actor(self, actor: ActorContext) -> ActorIdentity: - """Create or refresh identity and observed profiles for a verified actor. - - Args: - actor: Trusted actor context returned by Flow token verification. - - Returns: - Persisted actor identity. - """ - incoming_identity = self._identity_from_actor(actor) - existing_identity = await self._repo.get_identity(actor.actor_id, populate_existing=True) - if await self._can_skip_registry_refresh(actor, incoming_identity, existing_identity): - if existing_identity is None: - raise RuntimeError("actor registry freshness check returned an empty identity") - return existing_identity - - identity = await self._repo.upsert_identity(incoming_identity) - for profile_type in self._observed_profile_types(actor.roles): - await self.ensure_observed_profile( - actor, - profile_type=profile_type, - scope_type=GLOBAL_PROFILE_SCOPE_TYPE, - scope_id=GLOBAL_PROFILE_SCOPE_ID, - profile_metadata={"source": "verified_token_role"}, - identity_already_refreshed=True, - ) - for relationship in self._trusted_relationship_profiles(actor): - await self.ensure_observed_profile( - actor, - profile_type=relationship["profile_type"], - scope_type=relationship["scope_type"], - scope_id=relationship["scope_id"], - profile_metadata=relationship["profile_metadata"], - identity_already_refreshed=True, - ) - await self._session.commit() - await self._session.refresh(identity) - return identity + self._audit = AuditService(session) + self._legacy_audit = AuditRepository(session) + + async def find_verified_actor(self, token: VerifiedIssuerToken) -> ResolvedActor | None: + """Return a canonical actor for an existing exact identity link.""" + link = await self._repo.get_identity_link(token.issuer, token.subject) + if link is None: + return None + profile = await self._repo.get_actor_profile(link.actor_profile_id) + if profile is None: + raise RuntimeError("identity link references a missing actor profile") + self._validate_link(token, link, profile) + return ResolvedActor(profile=profile, identity_link=link) - async def ensure_observed_profile( + async def resolve_verified_actor( self, - actor: ActorContext, + token: VerifiedIssuerToken, *, - profile_type: str, - scope_type: str, - scope_id: str, - profile_metadata: dict | None = None, - identity_already_refreshed: bool = False, - ) -> ActorProfile: - """Create or refresh an observed profile without changing eligibility. - - Args: - actor: Trusted actor context. - profile_type: Profile type to observe. - scope_type: Profile scope namespace. - scope_id: Profile scope identifier. - profile_metadata: Non-authoritative metadata to store. - identity_already_refreshed: Whether the caller already refreshed - the trusted identity in the current request. - - Returns: - Persisted actor profile. - """ - self._validate_profile_type(profile_type) - if not identity_already_refreshed: - await self._repo.upsert_identity(self._identity_from_actor(actor)) - existing = await self._repo.get_profile(actor.actor_id, profile_type, scope_type, scope_id) - if existing is None: - inserted = await self._repo.insert_profile_if_absent( - ActorProfile( - id=str(uuid4()), - actor_id=actor.actor_id, - profile_type=profile_type, - status="observed", - skill_tags=[], - scope_type=scope_type, - scope_id=scope_id, - profile_metadata=profile_metadata or {}, - ) - ) - profile = await self._repo.get_profile(actor.actor_id, profile_type, scope_type, scope_id) - if profile is None: - raise RuntimeError("actor profile insert did not return a persisted row") - if inserted: - await self._write_profile_audit( - actor, - profile, - event_type="actor_profile_observed", - from_status=None, - to_status=profile.status, - event_payload={"profile_metadata": profile.profile_metadata}, - ) - return profile - - if existing.status != "observed": - return existing - - next_metadata = existing.profile_metadata if profile_metadata is None else profile_metadata - if existing.profile_metadata == next_metadata: - return existing - - previous_metadata = dict(existing.profile_metadata or {}) - existing.profile_metadata = next_metadata - existing.updated_at = func.now() - await self._write_profile_audit( - actor, - existing, - event_type="actor_profile_observation_refreshed", - from_status=existing.status, - to_status=existing.status, - event_payload={ - "previous_profile_metadata": previous_metadata, - "profile_metadata": existing.profile_metadata, - }, + request_id: UUID, + correlation_id: UUID, + ) -> ResolvedActor: + """Resolve an existing actor or atomically provision one verified human.""" + resolved = await self.find_verified_actor(token) + if resolved is not None: + return await self._touch_verified_actor(resolved) + if token.subject_kind == "service": + raise ServiceActorNotProvisioned("Service actor is not provisioned") + if token.subject_kind != "human": + raise UnsupportedSubjectKind("Unsupported subject kind") + + await self._repo.lock_external_identity(token.issuer, token.subject) + resolved = await self.find_verified_actor(token) + if resolved is not None: + return await self._touch_verified_actor(resolved) + + profile_id = actor_id_from_external_identity(token.issuer, token.subject) + profile = ActorProfile( + id=profile_id, + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + display_name=None, + contact_email=None, + created_by=profile_id, + last_seen_at=func.now(), + ) + link = ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=profile_id, + issuer=token.issuer, + subject=token.subject, + subject_kind="human", + status="active", + linked_by=profile_id, + ) + await self._repo.add_actor_profile(profile) + await self._repo.add_identity_link(link) + await self._write_provisioning_events( + profile, + link, + request_id=request_id, + correlation_id=correlation_id, ) - return existing + await self._session.commit() + await self._session.refresh(profile) + await self._session.refresh(link) + return ResolvedActor(profile=profile, identity_link=link) + + async def update_self( + self, + resolved: ResolvedActor, + payload: ActorProfileUpdateRequest, + ) -> ActorProfileSelfResponse: + """Update only the active human actor's owned display fields.""" + profile = await self._repo.get_actor_profile(resolved.profile.id, for_update=True) + if profile is None: + raise RuntimeError("resolved actor profile disappeared") + self._require_active_human(profile) + if "display_name" in payload.model_fields_set: + profile.display_name = payload.display_name + if "contact_email" in payload.model_fields_set: + profile.contact_email = payload.contact_email + profile.updated_at = func.now() + await self._session.commit() + await self._session.refresh(profile) + return self.self_response(profile) + + async def refresh_legacy_identity(self, actor: ActorContext) -> LegacyActorIdentity: + """Persist token observations only in the non-authoritative compatibility table.""" + identity = await self._repo.upsert_legacy_identity(self._legacy_identity_from_actor(actor)) + await self._session.commit() + return identity - async def activate_worker_profile( + async def activate_legacy_workflow_eligibility( self, actor: ActorContext, - payload: ActorProfileActivationRequest, - *, - identity_already_refreshed: bool = False, - ) -> ActorProfileResponse: - """Activate or refresh the current actor's worker profile. - - Args: - actor: Trusted actor context. - payload: Worker-owned profile fields. - identity_already_refreshed: Whether the request dependency already - refreshed the identity row. - - Returns: - Profile response joined with trusted identity metadata. - - Raises: - ActorProfileDisabled: If the worker profile has been disabled. - """ + payload: LegacyWorkflowEligibilityActivationRequest, + ) -> LegacyWorkflowEligibilityResponse: + """Activate temporary submitter intake metadata without creating authority.""" require_any_role(actor, {"worker"}) - identity = await self._identity_for_profile_mutation( - actor, - identity_already_refreshed=identity_already_refreshed, - ) - profile = await self._repo.get_profile( + identity = await self._repo.upsert_legacy_identity(self._legacy_identity_from_actor(actor)) + eligibility = await self._repo.get_legacy_eligibility( actor.actor_id, "worker", GLOBAL_PROFILE_SCOPE_TYPE, GLOBAL_PROFILE_SCOPE_ID, ) - if profile is None: - inserted = await self._repo.insert_profile_if_absent( - ActorProfile( - id=str(uuid4()), - actor_id=actor.actor_id, - profile_type="worker", - status="active", - skill_tags=payload.skill_tags, - scope_type=GLOBAL_PROFILE_SCOPE_TYPE, - scope_id=GLOBAL_PROFILE_SCOPE_ID, - profile_metadata={"source": "worker_profile_api"}, - ) + previous_status = None + previous_tags: list[str] = [] + inserted = False + if eligibility is None: + eligibility = LegacyWorkflowEligibility( + id=str(uuid4()), + actor_id=actor.actor_id, + profile_type="worker", + status="active", + skill_tags=payload.skill_tags, + scope_type=GLOBAL_PROFILE_SCOPE_TYPE, + scope_id=GLOBAL_PROFILE_SCOPE_ID, + profile_metadata={"source": "legacy_worker_profile_api"}, ) - profile = await self._repo.get_profile( + inserted = await self._repo.insert_legacy_eligibility_if_absent(eligibility) + eligibility = await self._repo.get_legacy_eligibility( actor.actor_id, "worker", GLOBAL_PROFILE_SCOPE_TYPE, GLOBAL_PROFILE_SCOPE_ID, ) - if profile is None: - raise RuntimeError("worker actor profile insert did not return a persisted row") - if inserted: - await self._write_profile_audit( - actor, - profile, - event_type="actor_profile_activated", - from_status=None, - to_status="active", - event_payload={"skill_tags": profile.skill_tags}, - ) + if eligibility is None: + raise RuntimeError("legacy eligibility insert did not return a row") else: - if profile.status == "disabled": - raise ActorProfileDisabled("worker profile is disabled") - from_status = profile.status - previous_tags = list(profile.skill_tags) - profile.status = "active" - profile.skill_tags = payload.skill_tags - profile.profile_metadata = { - **(profile.profile_metadata or {}), - "source": "worker_profile_api", - } - if from_status != profile.status or previous_tags != profile.skill_tags: - await self._write_profile_audit( - actor, - profile, - event_type="actor_profile_activated", - from_status=from_status, - to_status=profile.status, - event_payload={ - "previous_skill_tags": previous_tags, - "skill_tags": profile.skill_tags, - }, - ) + if eligibility.status == "disabled": + raise ActorProfileDisabled("Legacy workflow eligibility is disabled") + previous_status = eligibility.status + previous_tags = list(eligibility.skill_tags) + eligibility.status = "active" + eligibility.skill_tags = payload.skill_tags + eligibility.profile_metadata = {"source": "legacy_worker_profile_api"} + eligibility.updated_at = func.now() + + if ( + inserted + or previous_status != eligibility.status + or previous_tags != eligibility.skill_tags + ): + await self._write_legacy_eligibility_audit( + actor, + eligibility, + from_status=previous_status, + ) await self._session.commit() - await self._session.refresh(identity) - await self._session.refresh(profile) - return self._profile_response(profile, identity) - - async def get_active_profile(self, actor_id: str, profile_type: str) -> ActorProfile | None: - """Load an active global profile when workflow eligibility requires it. - - Args: - actor_id: Stable actor id. - profile_type: Profile type required by the workflow. - - Returns: - Active profile when present; otherwise ``None``. - """ - profile = await self._repo.get_profile( - actor_id, - profile_type, - GLOBAL_PROFILE_SCOPE_TYPE, - GLOBAL_PROFILE_SCOPE_ID, + await self._session.refresh(eligibility) + return LegacyWorkflowEligibilityResponse( + id=eligibility.id, + actor_id=eligibility.actor_id, + profile_type=eligibility.profile_type, + status=eligibility.status, + skill_tags=list(eligibility.skill_tags), + scope_type=eligibility.scope_type, + scope_id=eligibility.scope_id, + profile_metadata=dict(eligibility.profile_metadata), + external_subject=identity.external_subject, + external_issuer=identity.external_issuer, + created_at=eligibility.created_at, + updated_at=eligibility.updated_at, ) - if profile is None or profile.status != "active": - return None - return profile - async def _can_skip_registry_refresh( - self, - actor: ActorContext, - incoming_identity: ActorIdentity, - existing_identity: ActorIdentity | None, - ) -> bool: - """Return whether actor registry persistence can be skipped safely.""" - if existing_identity is None: - return False - interval_seconds = get_settings().actor_registry_refresh_interval_seconds - if interval_seconds <= 0: - return False - if not self._identity_claims_match(existing_identity, incoming_identity): - return False - if not self._identity_seen_recently(existing_identity.last_seen_at, interval_seconds): - return False - required_profiles = [ - { - "profile_type": profile_type, - "scope_type": GLOBAL_PROFILE_SCOPE_TYPE, - "scope_id": GLOBAL_PROFILE_SCOPE_ID, - "profile_metadata": {"source": "verified_token_role"}, - } - for profile_type in self._observed_profile_types(actor.roles) - ] - required_profiles.extend(self._trusted_relationship_profiles(actor)) - if not required_profiles: - return True - - existing_profiles = { - (profile.profile_type, profile.scope_type, profile.scope_id): profile - for profile in await self._repo.list_profiles(actor.actor_id) - } - for required_profile in required_profiles: - profile = existing_profiles.get( - ( - required_profile["profile_type"], - required_profile["scope_type"], - required_profile["scope_id"], - ) - ) - if profile is None: - return False - if ( - profile.status == "observed" - and profile.profile_metadata != required_profile.get("profile_metadata") - ): - return False - return True - - def _identity_claims_match( - self, - existing_identity: ActorIdentity, - incoming_identity: ActorIdentity, - ) -> bool: - """Return whether stored identity fields match the trusted actor claims.""" - return ( - existing_identity.external_subject == incoming_identity.external_subject - and existing_identity.external_issuer == incoming_identity.external_issuer - and existing_identity.display_name == incoming_identity.display_name - and existing_identity.email == incoming_identity.email - and existing_identity.last_seen_roles == incoming_identity.last_seen_roles - and existing_identity.last_claim_snapshot == incoming_identity.last_claim_snapshot - and existing_identity.auth_source == incoming_identity.auth_source - and existing_identity.is_dev_auth == incoming_identity.is_dev_auth + @staticmethod + def self_response(profile: ActorProfile) -> ActorProfileSelfResponse: + """Build the fixed no-grants Contributor-domain response.""" + if profile.actor_kind != "human": + raise UnsupportedSubjectKind("Unsupported subject kind") + if profile.status == "deactivated": + raise ActorDeactivated("Actor is deactivated") + return ActorProfileSelfResponse( + actor_profile_id=profile.id, + actor_kind="human", + status=profile.status, + display_name=profile.display_name, + contact_email=profile.contact_email, + created_at=profile.created_at, + updated_at=profile.updated_at, + last_seen_at=profile.last_seen_at, ) - def _identity_seen_recently( - self, - last_seen_at: datetime | None, - interval_seconds: int, - ) -> bool: - """Return whether an identity row is still within the refresh window.""" - if last_seen_at is None: - return False - comparable_last_seen = last_seen_at - if comparable_last_seen.tzinfo is None: - comparable_last_seen = comparable_last_seen.replace(tzinfo=UTC) - return datetime.now(UTC) - comparable_last_seen <= timedelta(seconds=interval_seconds) - - async def _identity_for_profile_mutation( - self, - actor: ActorContext, - *, - identity_already_refreshed: bool, - ) -> ActorIdentity: - """Return the current identity row for a profile mutation.""" - if not identity_already_refreshed: - return await self._repo.upsert_identity(self._identity_from_actor(actor)) - - identity = await self._repo.get_identity(actor.actor_id, populate_existing=True) - if identity is not None: - return identity - return await self._repo.upsert_identity(self._identity_from_actor(actor)) - - def _identity_from_actor(self, actor: ActorContext) -> ActorIdentity: - """Build an identity model from trusted actor claims.""" - return ActorIdentity( + async def _touch_verified_actor(self, resolved: ResolvedActor) -> ResolvedActor: + """Persist database-time verification for one existing actor.""" + await self._repo.touch_verified_actor(resolved.profile, resolved.identity_link) + await self._session.commit() + await self._session.refresh(resolved.profile) + await self._session.refresh(resolved.identity_link) + return resolved + + @staticmethod + def _legacy_identity_from_actor(actor: ActorContext) -> LegacyActorIdentity: + """Project privacy-bounded compatibility fields from verified context.""" + return LegacyActorIdentity( actor_id=actor.actor_id, external_subject=actor.external_subject, external_issuer=actor.external_issuer, - display_name=actor.display_name, - email=actor.email, + display_name=None, + email=None, last_seen_roles=list(actor.roles), - last_claim_snapshot=sanitized_claim_snapshot(actor.claim_snapshot), + last_claim_snapshot={"roles": list(actor.roles)}, auth_source=actor.auth_source, is_dev_auth=actor.is_dev_auth, ) - def _profile_response( + @staticmethod + def _validate_link( + token: VerifiedIssuerToken, + link: ActorIdentityLink, + profile: ActorProfile, + ) -> None: + if link.subject_kind != token.subject_kind or profile.actor_kind != token.subject_kind: + raise UnsupportedSubjectKind("Subject kind does not match actor") + if link.status != "active": + raise IdentityLinkRevoked("Identity link is revoked") + + @staticmethod + def _require_active_human(profile: ActorProfile) -> None: + if profile.actor_kind != "human": + raise UnsupportedSubjectKind("Unsupported subject kind") + if profile.status == "suspended": + raise ActorSuspended("Actor is suspended") + if profile.status == "deactivated": + raise ActorDeactivated("Actor is deactivated") + + async def _write_provisioning_events( self, profile: ActorProfile, - identity: ActorIdentity, - ) -> ActorProfileResponse: - """Build a profile response from profile and identity rows.""" - return ActorProfileResponse( - id=profile.id, - actor_id=profile.actor_id, - profile_type=profile.profile_type, - status=profile.status, - skill_tags=profile.skill_tags, - scope_type=profile.scope_type, - scope_id=profile.scope_id, - profile_metadata=profile.profile_metadata, - external_subject=identity.external_subject, - external_issuer=identity.external_issuer, - display_name=identity.display_name, - email=identity.email, - created_at=profile.created_at, - updated_at=profile.updated_at, + link: ActorIdentityLink, + *, + request_id: UUID, + correlation_id: UUID, + ) -> None: + common = { + "actor_ref_kind": ActorReferenceKind.ACTOR_PROFILE, + "actor_ref": profile.id, + "request_id": request_id, + "correlation_id": correlation_id, + "target_actor_ref_kind": ActorReferenceKind.ACTOR_PROFILE, + "target_actor_ref": profile.id, + } + await self._audit.add_authority_event( + AuthorityAuditEventInput( + event_id=uuid4(), + event_type=AuthorityEventType.ACTOR_PROFILE_PROVISIONED, + entity_type="actor_profile", + entity_id=profile.id, + resource_type="actor_profile", + resource_id=profile.id, + target_ref_kind="actor_profile", + target_ref_id=profile.id, + reason="automatic_first_access", + after_facts={ + "status": "active", + "subject_kind": "human", + "provisioning_method": "automatic_first_access", + }, + **common, + ) ) - - def _observed_profile_types(self, roles: Iterable[str]) -> list[str]: - """Return profile types that may be observed from verified token roles.""" - profile_types: list[str] = [] - for role in roles: - if role in TOKEN_OBSERVED_PROFILE_TYPES and role not in profile_types: - profile_types.append(role) - return profile_types - - def _trusted_relationship_profiles(self, actor: ActorContext) -> list[dict]: - """Extract trusted scoped relationship profiles from actor claims. - - Returns: - Sanitized project-owner relationship profile claims. - """ - profiles: list[dict] = [] - for raw_profile in normalized_relationship_profile_claims(actor.claim_snapshot): - profiles.append( - { - "profile_type": raw_profile["profile_type"], - "scope_type": raw_profile["scope_type"], - "scope_id": raw_profile["scope_id"], - "profile_metadata": {"source": "trusted_relationship_claim"}, - } + await self._audit.add_authority_event( + AuthorityAuditEventInput( + event_id=uuid4(), + event_type=AuthorityEventType.ACTOR_IDENTITY_LINKED, + entity_type="actor_identity_link", + entity_id=link.id, + resource_type="actor_identity_link", + resource_id=link.id, + target_ref_kind="actor_identity_link", + target_ref_id=link.id, + reason="identity_lifecycle_change", + after_facts={"status": "active", "subject_kind": "human"}, + **common, ) - return profiles - - def _validate_profile_type(self, profile_type: str) -> None: - """Validate profile type before persistence.""" - if profile_type not in ACTOR_PROFILE_TYPES: - raise ValueError(f"unsupported actor profile type: {profile_type}") + ) - async def _write_profile_audit( + async def _write_legacy_eligibility_audit( self, actor: ActorContext, - profile: ActorProfile, + eligibility: LegacyWorkflowEligibility, *, - event_type: str, from_status: str | None, - to_status: str | None, - event_payload: dict, ) -> None: - """Write audit evidence for profile eligibility changes.""" audit = actor.audit_context() - await self._audit_repo.add_audit_event( + await self._legacy_audit.add_audit_event( AuditEvent( id=str(uuid4()), - entity_type="actor_profile", - entity_id=profile.id, - event_type=event_type, + entity_type="legacy_workflow_eligibility", + entity_id=eligibility.id, + event_type="legacy_workflow_eligibility_activated", from_status=from_status, - to_status=to_status, + to_status=eligibility.status, actor_id=audit.actor_id, external_subject=audit.external_subject, external_issuer=audit.external_issuer, @@ -469,13 +390,29 @@ async def _write_profile_audit( claim_snapshot=audit.claim_snapshot, auth_source=audit.auth_source, is_dev_auth=audit.is_dev_auth, - reason=None, - event_payload={ - "actor_id": profile.actor_id, - "profile_type": profile.profile_type, - "scope_type": profile.scope_type, - "scope_id": profile.scope_id, - **event_payload, - }, + reason="legacy_intake_compatibility", + event_payload={"skill_tags": list(eligibility.skill_tags)}, ) ) + + +class LegacyWorkflowEligibilityCompatibility: + """Enumerated read-only bridge for task eligibility during staged cutover.""" + + def __init__(self, session: AsyncSession) -> None: + self._repository = ActorRepository(session) + + async def get_active_submitter_eligibility( + self, + actor_profile_id: str, + ) -> LegacyWorkflowEligibility | None: + """Return active legacy submitter metadata without granting permission.""" + eligibility = await self._repository.get_legacy_eligibility( + actor_profile_id, + "worker", + GLOBAL_PROFILE_SCOPE_TYPE, + GLOBAL_PROFILE_SCOPE_ID, + ) + if eligibility is None or eligibility.status != "active": + return None + return eligibility diff --git a/backend/app/modules/tasks/router.py b/backend/app/modules/tasks/router.py index f157c4c1..734e60bf 100644 --- a/backend/app/modules/tasks/router.py +++ b/backend/app/modules/tasks/router.py @@ -8,11 +8,14 @@ from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps.auth import get_registered_actor +from app.api.deps.auth import actor_registry_http_error, get_registered_actor from app.core.api_controls import error_response from app.core.permissions import PermissionDenied from app.db.session import get_db_session -from app.modules.actors.schemas import ActorProfileActivationRequest, ActorProfileResponse +from app.modules.actors.schemas import ( + LegacyWorkflowEligibilityActivationRequest, + LegacyWorkflowEligibilityResponse, +) from app.modules.actors.service import ActorRegistryError, ActorService from app.modules.tasks.schemas import ( AuditEventResponse, @@ -112,27 +115,18 @@ def permission_http_error(exc: PermissionDenied) -> HTTPException: return HTTPException(status_code=403, detail=str(exc)) -def actor_registry_http_error(exc: ActorRegistryError) -> HTTPException: - """Convert an actor-registry failure into an HTTP error.""" - return HTTPException(status_code=exc.status_code, detail=str(exc)) - - @router.post( "/workers/me/profile", - response_model=ActorProfileResponse, + response_model=LegacyWorkflowEligibilityResponse, ) async def ensure_worker_profile( - payload: ActorProfileActivationRequest, + payload: LegacyWorkflowEligibilityActivationRequest, actor: Annotated[ActorContext, Depends(get_registered_actor)], session: Annotated[AsyncSession, Depends(get_db_session)], -) -> ActorProfileResponse: - """Create or refresh the current worker profile from trusted Flow identity.""" +) -> LegacyWorkflowEligibilityResponse: + """Activate bounded legacy intake metadata without creating authority.""" try: - return await ActorService(session).activate_worker_profile( - actor, - payload, - identity_already_refreshed=True, - ) + return await ActorService(session).activate_legacy_workflow_eligibility(actor, payload) except PermissionDenied as exc: raise permission_http_error(exc) from exc except ActorRegistryError as exc: @@ -183,9 +177,7 @@ async def get_task( 422: { "description": "Locked task context is missing or inconsistent.", "content": { - "application/json": { - "schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA - } + "application/json": {"schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA} }, } }, @@ -215,9 +207,7 @@ async def get_task_work_context( 422: { "description": "Locked task context is missing or inconsistent.", "content": { - "application/json": { - "schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA - } + "application/json": {"schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA} }, } }, @@ -247,9 +237,7 @@ async def get_task_submission_requirements( 422: { "description": "Locked task context is missing or inconsistent.", "content": { - "application/json": { - "schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA - } + "application/json": {"schema": TASK_LOCKED_CONTEXT_DOMAIN_ERROR_RESPONSE_SCHEMA} }, } }, diff --git a/backend/app/modules/tasks/service.py b/backend/app/modules/tasks/service.py index 22f720f8..eee82491 100644 --- a/backend/app/modules/tasks/service.py +++ b/backend/app/modules/tasks/service.py @@ -27,8 +27,8 @@ CheckerServiceError, pre_review_gate_system_actor, ) -from app.modules.actors.models import ActorProfile -from app.modules.actors.service import ActorService +from app.modules.actors.models import LegacyWorkflowEligibility +from app.modules.actors.service import LegacyWorkflowEligibilityCompatibility from app.modules.projects.models import ( EffectiveProjectSubmissionArtifactPolicy, GuideSourceSnapshot, @@ -180,8 +180,8 @@ class TaskAssignmentConflict(TaskServiceError): status_code = 409 -class WorkerEligibilityRequired(TaskServiceError): - """Raised when a worker tries to claim without active worker profile eligibility.""" +class LegacySubmitterEligibilityRequired(TaskServiceError): + """Raised when a submitter lacks temporary legacy workflow eligibility.""" status_code = 403 @@ -269,7 +269,7 @@ def __init__(self, session: AsyncSession) -> None: self._session = session self._repo = TaskRepository(session) self._project_repo = ProjectRepository(session) - self._actor_service = ActorService(session) + self._legacy_workflow_eligibility = LegacyWorkflowEligibilityCompatibility(session) async def create_task( self, @@ -533,7 +533,7 @@ async def claim_task( require_any_role(actor, TASK_CLAIM_ROLES) task = await self._get_task(task_id) self._ensure_transition_allowed(task.status, TASK_STATUS_CLAIMED) - await self._require_active_worker_profile(actor) + await self._require_legacy_submitter_eligibility(actor) if await self._repo.get_active_assignment(task_id) is not None: raise TaskAssignmentConflict("task already has an active assignment") @@ -597,6 +597,8 @@ async def start_task( ) if assignment.worker_id != actor.actor_id and not is_operator_override: raise TaskTransitionBlocked("actor is not assigned to this task") + if not is_operator_override: + await self._require_legacy_submitter_eligibility(actor) if is_operator_override and (reason is None or not reason.strip()): raise TaskValidationError("operator start override reason is required") await self._change_task_status( @@ -642,7 +644,7 @@ async def create_submission( task = await self._get_task(task_id) if "worker" in actor.roles and task.assigned_to not in {None, actor.actor_id}: raise TaskNotFound("task not found") - await self._require_active_worker_profile(actor) + await self._require_legacy_submitter_eligibility(actor) assignment = await self._repo.get_active_assignment(task_id) if assignment is None: raise TaskTransitionBlocked("task has no active assignment") @@ -2018,24 +2020,28 @@ async def _validate_locked_post_submit_policy_context(self, task: WorkstreamTask except ValueError as exc: raise TaskProjectNotReady("locked post-submit checker policy hash is invalid") from exc - async def _require_active_worker_profile( + async def _require_legacy_submitter_eligibility( self, actor: ActorContext, - ) -> ActorProfile: - """Require active worker actor profile eligibility before claim. + ) -> LegacyWorkflowEligibility: + """Require temporary active submitter eligibility for intake workflows. Args: actor: Verified Flow actor context. Returns: - Persisted active worker actor profile. + Persisted active legacy submitter eligibility. Raises: - WorkerEligibilityRequired: If the actor has no active worker profile. + LegacySubmitterEligibilityRequired: If eligibility is absent or inactive. """ - profile = await self._actor_service.get_active_profile(actor.actor_id, "worker") + profile = await self._legacy_workflow_eligibility.get_active_submitter_eligibility( + actor.actor_id + ) if profile is None: - raise WorkerEligibilityRequired("active worker profile is required to claim task") + raise LegacySubmitterEligibilityRequired( + "active legacy submitter eligibility is required" + ) return profile async def _change_task_status( diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index f7c2dc6a..56988d9d 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -9,6 +9,7 @@ SubjectKind = Literal["human", "service", "agent", "space"] +MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS = 200 def normalize_legacy_roles(value: Any) -> tuple[str, ...]: @@ -20,9 +21,7 @@ def normalize_legacy_roles(value: Any) -> tuple[str, ...]: else: raw_roles = () roles = tuple( - role.strip() - for role in raw_roles - if isinstance(role, str) and 0 < len(role.strip()) <= 128 + role.strip() for role in raw_roles if isinstance(role, str) and 0 < len(role.strip()) <= 128 ) return roles[:32] @@ -32,8 +31,8 @@ class VerifiedIssuerToken(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) - issuer: str - subject: str + issuer: str = Field(min_length=1, max_length=MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS) + subject: str = Field(min_length=1, max_length=MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS) audience: tuple[str, ...] expires_at: int issued_at: int diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index f8f9f38b..1bc3a852 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -320,7 +320,7 @@ async def wait_for_health(base_url: str, process: subprocess.Popen, log_path: Pa Raises: RuntimeError: If the server exits or does not become healthy. """ - deadline = time.monotonic() + 20 + deadline = time.monotonic() + 60 async with httpx.AsyncClient(base_url=base_url, timeout=5) as client: while time.monotonic() < deadline: if process.poll() is not None: @@ -1074,6 +1074,16 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: worker = await request_json(client, "GET", "/api/v1/auth/me", worker_token) assert worker["roles"] == ["worker"] + canonical_actor = await request_json( + client, + "GET", + "/api/v1/actors/me", + worker_token, + ) + assert canonical_actor["actor_kind"] == "human" + assert canonical_actor["domains"] == ["contributor"] + assert canonical_actor["admin_roles"] == [] + assert canonical_actor["project_role_grants"] == [] worker_profile = await request_json( client, "POST", diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index ac466ac0..97c666e8 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -1,110 +1,92 @@ from __future__ import annotations import asyncio +import asyncpg from collections.abc import AsyncIterator, Iterator -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from pathlib import Path +from uuid import uuid4 -import pytest from alembic import command from alembic.config import Config from httpx import ASGITransport, AsyncClient -from sqlalchemy import delete, select, text +from pydantic import ValidationError +import pytest +from sqlalchemy import func, select, text +from sqlalchemy.exc import SQLAlchemyError -from app.adapters.auth.dev import actor_id_from_external_identity +from app.api.deps.auth import get_auth_verification_result +from app.api.deps.rate_controls import get_rate_control_service from app.core.config import get_settings from app.db import session as db_session from app.main import create_app -from app.modules.actors import legacy_classification as legacy_classification_module -from app.modules.actors.legacy_classification import snapshot_legacy_actors -from app.modules.actors.models import ActorIdentity, ActorProfile -from app.modules.actors.schemas import ActorProfileActivationRequest -from app.modules.actors.service import ActorService +from app.modules.actors.models import ( + ActorIdentityLink, + ActorProfile, + LegacyActorIdentity, + LegacyWorkflowEligibility, +) +from app.modules.actors.schemas import ( + ActorProfileUpdateRequest, + LegacyWorkflowEligibilityActivationRequest, +) +from app.modules.actors.service import ( + ActorDeactivated, + ActorProfileDisabled, + ActorService, + IdentityLinkRevoked, + LegacyWorkflowEligibilityCompatibility, + ServiceActorNotProvisioned, + UnsupportedSubjectKind, +) +from app.modules.api_controls.service import RateControlDecision, RateControlUnavailableError +from app.modules.audit.service import AuditService from app.modules.tasks.models import AuditEvent -from app.schemas.auth import ActorContext - - -async def test_legacy_classification_snapshot_is_complete_and_read_only( - actor_database_env: str, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Read exact legacy identity rows without changing registry state.""" - issuer = "https://issuer.example.test" - subject = "opaque-legacy-subject" - actor_id = actor_id_from_external_identity(issuer, subject) - async with db_session.get_session_factory()() as session: - session.add( - ActorIdentity( - actor_id=actor_id, - external_subject=subject, - external_issuer=issuer, - display_name=None, - email=None, - last_seen_roles=[], - last_claim_snapshot={}, - auth_source="flow", - is_dev_auth=False, +from app.schemas.auth import ( + ActorContext, + AuthVerificationResult, + VerifiedIssuerToken, + actor_id_from_external_identity, +) + +ISSUER = "https://identity.test" +RATE_SECRET = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" + + +async def clear_test_audit_events(database_url: str) -> None: + """Reset append-only evidence under explicit isolated-test ownership.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + async with connection.transaction(): + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_update_delete" ) - ) - await session.commit() - - observed_transaction: dict[str, str] = {} - original_reader = legacy_classification_module.read_legacy_actor_snapshot - - async def inspect_transaction(connection): - snapshot = await original_reader(connection) - observed_transaction["isolation"] = ( - await connection.exec_driver_sql("SHOW transaction_isolation") - ).scalar_one() - observed_transaction["read_only"] = ( - await connection.exec_driver_sql("SHOW transaction_read_only") - ).scalar_one() - return snapshot - - monkeypatch.setattr( - legacy_classification_module, - "read_legacy_actor_snapshot", - inspect_transaction, - ) - snapshot = await snapshot_legacy_actors(db_session.get_engine()) - - assert [(row.actor_id, row.issuer, row.subject) for row in snapshot.rows] == [ - (actor_id, issuer, subject) - ] - assert snapshot.database_binding.startswith("postgres-v1:") - assert observed_transaction == {"isolation": "repeatable read", "read_only": "on"} - async with db_session.get_session_factory()() as session: - assert len((await session.execute(select(ActorIdentity))).scalars().all()) == 1 - - engine = db_session.get_engine() - async with engine.connect() as connection: - connection = await connection.execution_options(isolation_level="REPEATABLE READ") - async with connection.begin(): - await connection.exec_driver_sql("SET TRANSACTION READ ONLY") - initial_count = ( - await connection.execute(text("SELECT count(*) FROM actor_identities")) - ).scalar_one() - concurrent_subject = "concurrent-legacy-subject" - async with db_session.get_session_factory()() as session: - session.add( - ActorIdentity( - actor_id=actor_id_from_external_identity(issuer, concurrent_subject), - external_subject=concurrent_subject, - external_issuer=issuer, - display_name=None, - email=None, - last_seen_roles=[], - last_claim_snapshot={}, - auth_source="flow", - is_dev_auth=False, - ) - ) - await session.commit() - repeated_count = ( - await connection.execute(text("SELECT count(*) FROM actor_identities")) - ).scalar_one() - - assert initial_count == repeated_count == 1 + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_truncate" + ) + await connection.execute("truncate table audit_events cascade") + await connection.execute("truncate table api_rate_control_counters") + await connection.execute( + "alter table actor_profiles disable trigger actor_profile_history_guard" + ) + await connection.execute( + "alter table actor_identity_links disable trigger actor_identity_link_history_guard" + ) + await connection.execute("truncate actor_identity_links, actor_profiles cascade") + await connection.execute( + "alter table actor_profiles enable trigger actor_profile_history_guard" + ) + await connection.execute( + "alter table actor_identity_links enable trigger actor_identity_link_history_guard" + ) + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_truncate" + ) + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_update_delete" + ) + finally: + await connection.close() @pytest.fixture @@ -113,17 +95,19 @@ def actor_database_env( postgres_database_url: str, migration_lock, ) -> Iterator[str]: - """Run actor registry tests against a migrated Postgres schema.""" + """Run canonical actor tests against an isolated current schema.""" monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) - set_dev_actor(monkeypatch, roles="worker", subject="actor-registry-worker") + monkeypatch.setenv("WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", RATE_SECRET) + set_dev_actor(monkeypatch, roles="worker", subject="actor-registry-contributor") get_settings.cache_clear() asyncio.run(db_session.dispose_engine()) - config = alembic_config() with migration_lock(): command.downgrade(config, "base") command.upgrade(config, "head") yield postgres_database_url + asyncio.run(clear_test_audit_events(postgres_database_url)) + asyncio.run(db_session.dispose_engine()) command.downgrade(config, "base") asyncio.run(db_session.dispose_engine()) get_settings.cache_clear() @@ -131,17 +115,14 @@ def actor_database_env( @pytest.fixture async def actor_client(actor_database_env: str) -> AsyncIterator[AsyncClient]: - """Yield an in-process client backed by the migrated actor database.""" app = create_app() async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://testserver", + transport=ASGITransport(app=app), base_url="http://testserver" ) as client: yield client def alembic_config() -> Config: - """Return Alembic configuration for backend migrations.""" project_root = Path(__file__).resolve().parents[1] config = Config(str(project_root / "alembic.ini")) config.set_main_option("script_location", str(project_root / "alembic")) @@ -154,655 +135,666 @@ def set_dev_actor( roles: str, subject: str, token: str = "actor-token", - issuer: str = "flow-test", - email: str | None = None, - display_name: str | None = None, + issuer: str = ISSUER, ) -> None: - """Configure the development verifier for one actor.""" monkeypatch.setenv("WORKSTREAM_AUTH_PROVIDER", "dev") monkeypatch.setenv("WORKSTREAM_ENVIRONMENT", "test") monkeypatch.setenv("WORKSTREAM_DEV_AUTH_TOKEN", token) monkeypatch.setenv("WORKSTREAM_DEV_AUTH_SUBJECT", subject) monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ISSUER", issuer) - monkeypatch.setenv("WORKSTREAM_DEV_AUTH_EMAIL", email or f"{subject}@example.test") - monkeypatch.setenv( - "WORKSTREAM_DEV_AUTH_DISPLAY_NAME", - display_name or subject.replace("-", " ").title(), - ) monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", roles) get_settings.cache_clear() def auth_headers(token: str = "actor-token") -> dict[str, str]: - """Return bearer auth headers for actor registry tests.""" return {"Authorization": f"Bearer {token}"} -def actor_id(subject: str, issuer: str = "flow-test") -> str: - """Return the stable actor id for a test issuer and subject.""" - return actor_id_from_external_identity(issuer, subject) +def verified_token(subject: str, *, kind: str = "human") -> VerifiedIssuerToken: + now = int(datetime.now(UTC).timestamp()) + return VerifiedIssuerToken( + issuer=ISSUER, + subject=subject, + audience=("workstream",), + expires_at=now + 300, + issued_at=now, + token_id=f"token-{subject}", + subject_kind=kind, + scopes=frozenset({"workstream:access" if kind == "human" else "workstream:service"}), + ) -def actor_context( - *, - subject: str, - roles: tuple[str, ...], - claim_snapshot: dict | None = None, -) -> ActorContext: - """Build a trusted actor context for service-level actor tests.""" +def legacy_actor(subject: str, roles: tuple[str, ...] = ("worker",)) -> ActorContext: return ActorContext( - actor_id=actor_id(subject), + actor_id=actor_id_from_external_identity(ISSUER, subject), external_subject=subject, - external_issuer="flow-test", - email=f"{subject}@example.test", - display_name=subject.replace("-", " ").title(), + external_issuer=ISSUER, roles=roles, - claim_snapshot=claim_snapshot or {"roles": roles}, + claim_snapshot={"roles": list(roles)}, auth_source="dev_mock", is_dev_auth=True, ) -async def test_auth_me_registers_identity_and_observed_profiles_without_duplicates( - actor_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, +async def test_first_human_access_atomically_creates_profile_link_and_events( + actor_database_env: str, ) -> None: - set_dev_actor(monkeypatch, roles="worker,reviewer", subject="observed-actor") - - first = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) - second = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + token = verified_token("first-human") + async with db_session.get_session_factory()() as session: + resolved = await ActorService(session).resolve_verified_actor( + token, + request_id=uuid4(), + correlation_id=uuid4(), + ) - assert first.status_code == 200, first.text - assert second.status_code == 200, second.text + assert resolved.profile.id == actor_id_from_external_identity(ISSUER, token.subject) + assert resolved.profile.actor_kind == "human" + assert resolved.profile.status == "active" + assert resolved.profile.provisioning_method == "automatic_first_access" + assert resolved.identity_link.actor_profile_id == resolved.profile.id + assert resolved.identity_link.subject_kind == "human" async with db_session.get_session_factory()() as session: - identity_rows = ( - await session.execute( - select(ActorIdentity).where(ActorIdentity.actor_id == actor_id("observed-actor")) - ) - ).scalars().all() - profile_rows = ( - await session.execute( - select(ActorProfile) - .where(ActorProfile.actor_id == actor_id("observed-actor")) - .order_by(ActorProfile.profile_type.asc()) + events = ( + await session.scalars( + select(AuditEvent) + .where(AuditEvent.entity_id.in_([resolved.profile.id, resolved.identity_link.id])) + .order_by(AuditEvent.created_at, AuditEvent.id) ) - ).scalars().all() - - assert len(identity_rows) == 1 - assert identity_rows[0].last_seen_roles == ["worker", "reviewer"] - assert [(profile.profile_type, profile.status) for profile in profile_rows] == [ - ("reviewer", "observed"), - ("worker", "observed"), - ] + ).all() + assert {event.event_type for event in events} == { + "ActorProfileProvisioned", + "ActorIdentityLinked", + } + assert len(events) == 2 + assert all(event.idempotency_reference is None for event in events) + assert all(event.invalidation_cause_event_id is None for event in events) -async def test_repeated_auth_me_does_not_rewrite_unchanged_observed_profile( - actor_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, +async def test_concurrent_first_access_leaves_one_profile_link_and_event_pair( + actor_database_env: str, ) -> None: - set_dev_actor(monkeypatch, roles="worker", subject="freshness-worker") - first = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) - assert first.status_code == 200, first.text - stale_time = datetime.now(UTC) - timedelta(days=1) - async with db_session.get_session_factory()() as session: - profile = await session.scalar( - select(ActorProfile).where( - ActorProfile.actor_id == actor_id("freshness-worker"), - ActorProfile.profile_type == "worker", + token = verified_token("concurrent-human") + + async def resolve(): + async with db_session.get_session_factory()() as session: + return await ActorService(session).resolve_verified_actor( + token, + request_id=uuid4(), + correlation_id=uuid4(), ) - ) - assert profile is not None - profile.updated_at = stale_time - await session.commit() - second = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) - assert second.status_code == 200, second.text + first, second = await asyncio.gather(resolve(), resolve()) + assert first.profile.id == second.profile.id + assert first.identity_link.id == second.identity_link.id async with db_session.get_session_factory()() as session: - profile_rows = ( - await session.execute( - select(ActorProfile).where( - ActorProfile.actor_id == actor_id("freshness-worker"), - ActorProfile.profile_type == "worker", + assert await session.scalar(select(func.count()).select_from(ActorProfile)) == 1 + assert await session.scalar(select(func.count()).select_from(ActorIdentityLink)) == 1 + assert ( + await session.scalar( + select(func.count()) + .select_from(AuditEvent) + .where( + AuditEvent.event_type.in_(["ActorProfileProvisioned", "ActorIdentityLinked"]) ) ) - ).scalars().all() - - assert len(profile_rows) == 1 - assert profile_rows[0].status == "observed" - assert profile_rows[0].updated_at == stale_time + == 2 + ) -async def test_auth_me_refreshes_stale_observed_profile_metadata( - actor_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, +async def test_repeated_verified_access_reuses_actor_and_advances_database_timestamps( + actor_database_env: str, ) -> None: - set_dev_actor(monkeypatch, roles="reviewer", subject="metadata-refresh-reviewer") - created = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) - assert created.status_code == 200, created.text - stale_time = datetime.now(UTC) - timedelta(days=1) + token = verified_token("repeat-human") async with db_session.get_session_factory()() as session: - profile = await session.scalar( - select(ActorProfile).where( - ActorProfile.actor_id == actor_id("metadata-refresh-reviewer"), - ActorProfile.profile_type == "reviewer", - ) + first = await ActorService(session).resolve_verified_actor( + token, + request_id=uuid4(), + correlation_id=uuid4(), ) - assert profile is not None - profile.profile_metadata = {"source": "stale"} - profile.updated_at = stale_time - await session.commit() + first_profile_seen_at = first.profile.last_seen_at + first_link_verified_at = first.identity_link.last_verified_at - refreshed = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) - assert refreshed.status_code == 200, refreshed.text + assert first_profile_seen_at is not None + assert first_link_verified_at is not None + await asyncio.sleep(0.01) async with db_session.get_session_factory()() as session: - profile = await session.scalar( - select(ActorProfile).where( - ActorProfile.actor_id == actor_id("metadata-refresh-reviewer"), - ActorProfile.profile_type == "reviewer", - ) + second = await ActorService(session).resolve_verified_actor( + token, + request_id=uuid4(), + correlation_id=uuid4(), ) - events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_type == "actor_profile", - AuditEvent.actor_id == actor_id("metadata-refresh-reviewer"), - AuditEvent.event_type == "actor_profile_observation_refreshed", - ) - ) - ).scalars().all() - assert profile is not None - assert profile.profile_metadata == {"source": "verified_token_role"} - assert profile.updated_at > stale_time - assert len(events) == 1 - assert events[0].from_status == "observed" - assert events[0].to_status == "observed" - assert events[0].event_payload["profile_type"] == "reviewer" - assert events[0].event_payload["previous_profile_metadata"] == {"source": "stale"} - assert events[0].event_payload["profile_metadata"] == {"source": "verified_token_role"} + assert second.profile.id == first.profile.id + assert second.identity_link.id == first.identity_link.id + assert second.profile.last_seen_at > first_profile_seen_at + assert second.identity_link.last_verified_at > first_link_verified_at + async with db_session.get_session_factory()() as session: + assert await session.scalar(select(func.count()).select_from(ActorProfile)) == 1 + assert await session.scalar(select(func.count()).select_from(ActorIdentityLink)) == 1 -async def test_auth_me_refreshes_identity_after_configured_interval( - actor_client: AsyncClient, +async def test_first_access_rolls_back_profile_link_and_first_audit_on_second_audit_failure( + actor_database_env: str, monkeypatch: pytest.MonkeyPatch, ) -> None: - set_dev_actor(monkeypatch, roles="worker", subject="stale-identity-worker") - created = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) - assert created.status_code == 200, created.text - stale_time = datetime.now(UTC) - timedelta(minutes=10) + token = verified_token("audit-rollback-human") + original = AuditService.add_authority_event + calls = 0 + + async def fail_second_event(self, value): + nonlocal calls + calls += 1 + if calls == 2: + raise RuntimeError("injected audit failure") + return await original(self, value) + + monkeypatch.setattr(AuditService, "add_authority_event", fail_second_event) async with db_session.get_session_factory()() as session: - identity = await session.scalar( - select(ActorIdentity).where( - ActorIdentity.actor_id == actor_id("stale-identity-worker") + with pytest.raises(RuntimeError, match="injected audit failure"): + await ActorService(session).resolve_verified_actor( + token, + request_id=uuid4(), + correlation_id=uuid4(), ) - ) - assert identity is not None - identity.last_seen_at = stale_time - await session.commit() + await session.rollback() - refreshed = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) - assert refreshed.status_code == 200, refreshed.text + actor_id = actor_id_from_external_identity(ISSUER, token.subject) async with db_session.get_session_factory()() as session: - identity = await session.scalar( - select(ActorIdentity).where( - ActorIdentity.actor_id == actor_id("stale-identity-worker") + assert await session.get(ActorProfile, actor_id) is None + assert ( + await session.scalar( + select(func.count()) + .select_from(ActorIdentityLink) + .where(ActorIdentityLink.actor_profile_id == actor_id) ) + == 0 + ) + assert ( + await session.scalar( + select(func.count()).select_from(AuditEvent).where(AuditEvent.actor_id == actor_id) + ) + == 0 ) - - assert identity is not None - assert identity.last_seen_at > stale_time -async def test_zero_registry_refresh_interval_writes_identity_every_time( - actor_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, +@pytest.mark.parametrize("kind", ["agent", "space"]) +async def test_unsupported_subject_kinds_create_nothing( + actor_database_env: str, + kind: str, ) -> None: - monkeypatch.setenv("WORKSTREAM_ACTOR_REGISTRY_REFRESH_INTERVAL_SECONDS", "0") - get_settings.cache_clear() - set_dev_actor(monkeypatch, roles="worker", subject="always-refresh-worker") - first = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) - assert first.status_code == 200, first.text - stale_time = datetime.now(UTC) - timedelta(minutes=10) async with db_session.get_session_factory()() as session: - identity = await session.scalar( - select(ActorIdentity).where( - ActorIdentity.actor_id == actor_id("always-refresh-worker") + with pytest.raises(UnsupportedSubjectKind): + await ActorService(session).resolve_verified_actor( + verified_token(f"unsupported-{kind}", kind=kind), + request_id=uuid4(), + correlation_id=uuid4(), ) - ) - assert identity is not None - identity.last_seen_at = stale_time - await session.commit() + await session.rollback() + async with db_session.get_session_factory()() as session: + assert await session.scalar(select(func.count()).select_from(ActorProfile)) == 0 + assert await session.scalar(select(func.count()).select_from(ActorIdentityLink)) == 0 - second = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) - assert second.status_code == 200, second.text + +async def test_unknown_service_creates_nothing(actor_database_env: str) -> None: async with db_session.get_session_factory()() as session: - identity = await session.scalar( - select(ActorIdentity).where( - ActorIdentity.actor_id == actor_id("always-refresh-worker") + with pytest.raises(ServiceActorNotProvisioned): + await ActorService(session).resolve_verified_actor( + verified_token("unknown-service", kind="service"), + request_id=uuid4(), + correlation_id=uuid4(), ) - ) - - assert identity is not None - assert identity.last_seen_at > stale_time + await session.rollback() + async with db_session.get_session_factory()() as session: + assert await session.scalar(select(func.count()).select_from(ActorProfile)) == 0 -@pytest.mark.parametrize("role", ["worker", "reviewer", "admin", "project_manager"]) -async def test_token_roles_create_observed_non_eligibility_profiles( +async def test_actors_me_returns_contributor_without_token_role_authority( actor_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, - role: str, ) -> None: - subject = f"{role}-observed" - set_dev_actor(monkeypatch, roles=role, subject=subject) - - response = await actor_client.get("/api/v1/auth/me", headers=auth_headers()) + set_dev_actor( + monkeypatch, + roles="admin,project_manager,worker,reviewer", + subject="role-heavy-human", + ) + response = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) assert response.status_code == 200, response.text - async with db_session.get_session_factory()() as session: - profile = await session.scalar( - select(ActorProfile).where( - ActorProfile.actor_id == actor_id(subject), - ActorProfile.profile_type == role, - ) - ) - - assert profile is not None - assert profile.status == "observed" - assert profile.scope_type == "global" - assert profile.scope_id == "global" + body = response.json() + assert body["actor_kind"] == "human" + assert body["domains"] == ["contributor"] + assert body["admin_roles"] == [] + assert body["project_role_grants"] == [] + assert "issuer" not in body and "subject" not in body and "roles" not in body -async def test_worker_profile_activation_is_explicit_and_audited( +async def test_patch_actors_me_updates_only_display_fields( actor_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, ) -> None: - set_dev_actor(monkeypatch, roles="worker", subject="explicit-worker") - - response = await actor_client.post( - "/api/v1/workers/me/profile", + response = await actor_client.patch( + "/api/v1/actors/me", headers=auth_headers(), - json={ - "skill_tags": [" STEM ", "stem", "finance"], - "email": "spoof@example.test", - }, + json={"display_name": "Contributor One", "contact_email": "one@example.test"}, ) - assert response.status_code == 422 + assert response.status_code == 200, response.text + assert response.json()["display_name"] == "Contributor One" + assert response.json()["contact_email"] == "one@example.test" - profile_response = await actor_client.post( - "/api/v1/workers/me/profile", + unknown = await actor_client.patch( + "/api/v1/actors/me", headers=auth_headers(), - json={"skill_tags": [" STEM ", "stem", "finance"]}, + json={"status": "active"}, ) + assert unknown.status_code == 422 + empty = await actor_client.patch("/api/v1/actors/me", headers=auth_headers(), json={}) + assert empty.status_code == 422 - assert profile_response.status_code == 200, profile_response.text - body = profile_response.json() - assert body["profile_type"] == "worker" - assert body["status"] == "active" - assert body["skill_tags"] == ["stem", "finance"] - assert body["email"] is None - repeat_response = await actor_client.post( - "/api/v1/workers/me/profile", + +async def test_patch_actors_me_maps_database_failure_to_retryable_unavailable( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + created = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) + assert created.status_code == 200 + + async def fail_update(*_args, **_kwargs): + raise SQLAlchemyError("injected database failure") + + monkeypatch.setattr(ActorService, "update_self", fail_update) + response = await actor_client.patch( + "/api/v1/actors/me", headers=auth_headers(), - json={"skill_tags": ["stem", "finance"]}, + json={"display_name": "Not persisted"}, ) - assert repeat_response.status_code == 200, repeat_response.text + assert response.status_code == 503 + assert response.json()["error"]["code"] == "service_unavailable" + assert response.json()["error"]["retryable"] is True + + +async def test_suspended_profile_is_readable_but_not_mutable( + actor_client: AsyncClient, +) -> None: + created = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) + actor_profile_id = created.json()["actor_profile_id"] async with db_session.get_session_factory()() as session: - events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_type == "actor_profile", - AuditEvent.entity_id == body["id"], - ) - ) - ).scalars().all() + profile = await session.get(ActorProfile, actor_profile_id) + profile.status = "suspended" + profile.suspended_by = actor_profile_id + profile.suspended_at = func.now() + profile.suspension_reason = "security response" + await session.commit() - activation_events = [ - event for event in events if event.event_type == "actor_profile_activated" - ] - assert len(activation_events) == 1 - assert activation_events[0].from_status == "observed" - assert activation_events[0].to_status == "active" - assert activation_events[0].event_payload["profile_type"] == "worker" - assert activation_events[0].event_payload["scope_type"] == "global" - assert activation_events[0].event_payload["scope_id"] == "global" - assert activation_events[0].event_payload["skill_tags"] == ["stem", "finance"] + read = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) + assert read.status_code == 200 + assert read.json()["status"] == "suspended" + patch = await actor_client.patch( + "/api/v1/actors/me", + headers=auth_headers(), + json={"display_name": "Blocked"}, + ) + assert patch.status_code == 403 + assert patch.json()["error"]["code"] == "actor_suspended" -async def test_observation_preserves_active_and_disabled_statuses( - actor_database_env: str, +async def test_revoked_identity_link_is_denied_by_actor_api( + actor_client: AsyncClient, ) -> None: - active_actor = actor_context(subject="active-preserved", roles=("worker",)) - disabled_actor = actor_context(subject="disabled-preserved", roles=("worker",)) + created = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) + assert created.status_code == 200 + actor_profile_id = created.json()["actor_profile_id"] async with db_session.get_session_factory()() as session: - service = ActorService(session) - await service.activate_worker_profile( - active_actor, - ActorProfileActivationRequest(skill_tags=["stem"]), - ) - await service.activate_worker_profile( - disabled_actor, - ActorProfileActivationRequest(skill_tags=["stem"]), + link = await session.scalar( + select(ActorIdentityLink).where(ActorIdentityLink.actor_profile_id == actor_profile_id) ) - disabled_profile = await service.get_active_profile(disabled_actor.actor_id, "worker") - assert disabled_profile is not None - disabled_profile.status = "disabled" + assert link is not None + link.status = "revoked" + link.revoked_by = actor_profile_id + link.revoked_at = func.now() + link.revoked_reason = "security response" await session.commit() - await service.register_actor(active_actor) - await service.register_actor(disabled_actor) + denied = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) + assert denied.status_code == 403 + assert denied.json()["error"]["code"] == "identity_link_revoked" + +async def test_deactivated_actor_is_denied_by_actor_self_api( + actor_client: AsyncClient, +) -> None: + created = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) + assert created.status_code == 200 + actor_profile_id = created.json()["actor_profile_id"] async with db_session.get_session_factory()() as session: - active_profile = await session.scalar( - select(ActorProfile).where(ActorProfile.actor_id == active_actor.actor_id) - ) - disabled_profile = await session.scalar( - select(ActorProfile).where(ActorProfile.actor_id == disabled_actor.actor_id) + profile = await session.get(ActorProfile, actor_profile_id) + assert profile is not None + profile.status = "deactivated" + profile.deactivated_by = actor_profile_id + profile.deactivated_at = func.now() + profile.deactivation_reason = "operator decision" + await session.commit() + + denied = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) + assert denied.status_code == 403 + assert denied.json()["error"]["code"] == "actor_deactivated" + + +@pytest.mark.parametrize( + ("kind", "expected_code"), + [ + ("service", "service_actor_not_provisioned"), + ("agent", "unsupported_subject_kind"), + ("space", "unsupported_subject_kind"), + ], +) +async def test_nonhuman_actor_self_api_denials_create_nothing( + actor_client: AsyncClient, + kind: str, + expected_code: str, +) -> None: + async def verification_override() -> AuthVerificationResult: + return AuthVerificationResult( + token=verified_token(f"http-{kind}", kind=kind), + legacy=None, ) - assert active_profile is not None - assert active_profile.status == "active" - assert active_profile.profile_metadata == {"source": "worker_profile_api"} - assert disabled_profile is not None - assert disabled_profile.status == "disabled" - assert disabled_profile.profile_metadata == {"source": "worker_profile_api"} + actor_client._transport.app.dependency_overrides[get_auth_verification_result] = ( # type: ignore[attr-defined] + verification_override + ) + response = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) + + assert response.status_code == 403 + assert response.json()["error"]["code"] == expected_code + async with db_session.get_session_factory()() as session: + assert await session.scalar(select(func.count()).select_from(ActorProfile)) == 0 + assert await session.scalar(select(func.count()).select_from(ActorIdentityLink)) == 0 + +async def test_actor_api_accepts_verifier_identity_bounds( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + issuer = ("https://identity.test/" + "i" * 200)[:200] + subject = "s" * 200 + set_dev_actor(monkeypatch, roles="worker", subject=subject, issuer=issuer) + + response = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) -async def test_observation_can_explicitly_clear_metadata(actor_database_env: str) -> None: - actor = actor_context(subject="metadata-clear-worker", roles=("worker",)) + assert response.status_code == 200, response.text async with db_session.get_session_factory()() as session: - service = ActorService(session) - await service.ensure_observed_profile( - actor, - profile_type="worker", - scope_type="global", - scope_id="global", - profile_metadata={"source": "initial"}, - ) - await service.ensure_observed_profile( - actor, - profile_type="worker", - scope_type="global", - scope_id="global", - profile_metadata={}, + link = await session.scalar( + select(ActorIdentityLink).where( + ActorIdentityLink.issuer == issuer, + ActorIdentityLink.subject == subject, + ) ) - await session.commit() + assert link is not None + eligibility = await actor_client.post( + "/api/v1/workers/me/profile", + headers=auth_headers(), + json={"skill_tags": ["stem"]}, + ) + assert eligibility.status_code == 200, eligibility.text async with db_session.get_session_factory()() as session: - profile = await session.scalar( - select(ActorProfile).where( - ActorProfile.actor_id == actor.actor_id, - ActorProfile.profile_type == "worker", + event = await session.scalar( + select(AuditEvent).where( + AuditEvent.event_type == "legacy_workflow_eligibility_activated" ) ) + assert event is not None + assert event.external_issuer == issuer + assert event.external_subject == subject + + +def test_verified_identity_rejects_values_above_persisted_provenance_bound() -> None: + with pytest.raises(ValidationError): + verified_token("s" * 201) + with pytest.raises(ValidationError): + now = int(datetime.now(UTC).timestamp()) + VerifiedIssuerToken( + issuer="i" * 201, + subject="subject", + audience=("workstream",), + expires_at=now + 300, + issued_at=now, + token_id="oversized-issuer", + subject_kind="human", + scopes=frozenset({"workstream:access"}), + ) - assert profile is not None - assert profile.profile_metadata == {} +async def test_first_access_rate_limit_denies_without_actor_write( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class DeniedRateControl: + async def consume(self, **_kwargs): + return RateControlDecision(allowed=False, request_count=2, retry_after=30) -async def test_scoped_project_owner_profile_comes_from_trusted_relationship_claim( - actor_database_env: str, + actor_client._transport.app.dependency_overrides[get_rate_control_service] = ( # type: ignore[attr-defined] + lambda: DeniedRateControl() + ) + set_dev_actor(monkeypatch, roles="worker", subject="rate-denied-human") + response = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) + assert response.status_code == 429 + assert response.headers["retry-after"] == "30" + async with db_session.get_session_factory()() as session: + assert await session.scalar(select(func.count()).select_from(ActorProfile)) == 0 + + +async def test_first_access_rate_control_unavailable_fails_closed( + actor_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, ) -> None: - actor = actor_context( - subject="source-contact", - roles=("project_manager",), - claim_snapshot={ - "roles": [" project_manager ", ""], - "access_token": "must-not-persist", - "claim_source": "must-not-persist", - "email": "must-not-persist@example.test", - "nested": {"api_key": "must-not-persist"}, - "secret": "must-not-persist", - "workstream_relationship_profiles": [ - { - "profile_type": "project_owner", - "scope_type": "project", - "scope_id": "project-123", - "profile_metadata": { - "organization": "Example Labs", - "api_key": "must-not-persist", - }, - } - ] - }, + class UnavailableRateControl: + async def consume(self, **_kwargs): + raise RateControlUnavailableError("unavailable") + + actor_client._transport.app.dependency_overrides[get_rate_control_service] = ( # type: ignore[attr-defined] + lambda: UnavailableRateControl() ) + set_dev_actor(monkeypatch, roles="worker", subject="rate-unavailable-human") + response = await actor_client.get("/api/v1/actors/me", headers=auth_headers()) + assert response.status_code == 503 + assert response.json()["error"]["code"] == "service_unavailable" async with db_session.get_session_factory()() as session: - service = ActorService(session) - await service.register_actor(actor) - await service.register_actor(actor) + assert await session.scalar(select(func.count()).select_from(ActorProfile)) == 0 + assert await session.scalar(select(func.count()).select_from(ActorIdentityLink)) == 0 + assert ( + await session.scalar( + select(func.count()).select_from(AuditEvent).where( + AuditEvent.event_type.in_( + ["ActorProfileProvisioned", "ActorIdentityLinked"] + ) + ) + ) + == 0 + ) + +async def test_legacy_activation_writes_only_compatibility_metadata( + actor_database_env: str, +) -> None: + actor = legacy_actor("legacy-intake") async with db_session.get_session_factory()() as session: - identity = await session.scalar( - select(ActorIdentity).where(ActorIdentity.actor_id == actor.actor_id) + await ActorService(session).resolve_verified_actor( + verified_token("legacy-intake"), + request_id=uuid4(), + correlation_id=uuid4(), ) - profiles = ( - await session.execute( - select(ActorProfile).where( - ActorProfile.actor_id == actor.actor_id, - ActorProfile.profile_type == "project_owner", - ) + response = await ActorService(session).activate_legacy_workflow_eligibility( + actor, + LegacyWorkflowEligibilityActivationRequest(skill_tags=["STEM", "stem"]), + ) + assert response.status == "active" + assert response.skill_tags == ["stem"] + async with db_session.get_session_factory()() as session: + assert await session.get(LegacyActorIdentity, actor.actor_id) is not None + eligibility = await session.scalar( + select(LegacyWorkflowEligibility).where( + LegacyWorkflowEligibility.actor_id == actor.actor_id ) - ).scalars().all() - audit_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_type == "actor_profile", - AuditEvent.actor_id == actor.actor_id, - ) + ) + assert eligibility is not None + assert eligibility.profile_metadata == {"source": "legacy_worker_profile_api"} + table_names = set( + await session.scalars( + text("select tablename from pg_tables where schemaname=current_schema()") ) - ).scalars().all() - - assert identity is not None - identity_snapshot = identity.last_claim_snapshot - assert identity_snapshot["roles"] == ["project_manager"] - assert identity_snapshot["workstream_relationship_profiles"] == [ - { - "profile_type": "project_owner", - "scope_type": "project", - "scope_id": "project-123", - } - ] - assert "must-not-persist" not in str(identity_snapshot) - assert "api_key" not in str(identity_snapshot) - assert "access_token" not in str(identity_snapshot) - assert "claim_source" not in str(identity_snapshot) - assert "nested" not in str(identity_snapshot) - assert "email" not in str(identity_snapshot) - assert "secret" not in str(identity_snapshot).lower() - assert "token" not in str(identity_snapshot).lower() - assert len(profiles) == 1 - assert profiles[0].status == "observed" - assert profiles[0].scope_type == "project" - assert profiles[0].scope_id == "project-123" - assert profiles[0].profile_metadata == {"source": "trusted_relationship_claim"} - assert audit_events - for audit_event in audit_events: - claim_snapshot = audit_event.claim_snapshot - assert "must-not-persist" not in str(claim_snapshot) - assert "api_key" not in str(claim_snapshot) - assert "access_token" not in str(claim_snapshot) - assert "claim_source" not in str(claim_snapshot) - assert "nested" not in str(claim_snapshot) - assert "email" not in str(claim_snapshot) - assert "secret" not in str(claim_snapshot).lower() - assert "token" not in str(claim_snapshot).lower() - - -async def test_relationship_claims_ignore_malformed_entries_and_observe_multiple_scopes( + ) + assert "admin_role_grants" not in table_names + assert "project_role_grants" not in table_names + + +async def test_repeated_legacy_activation_updates_one_row_and_audits_only_changes( actor_database_env: str, ) -> None: - actor = actor_context( - subject="multi-project-owner", - roles=("project_manager",), - claim_snapshot={ - "roles": ["project_manager"], - "workstream_relationship_profiles": [ - { - "profile_type": "project_owner", - "scope_type": " project ", - "scope_id": " project-alpha ", - }, - { - "profile_type": "reviewer", - "scope_type": "project", - "scope_id": "project-ignored", - }, - { - "profile_type": "project_owner", - "scope_type": "project", - "scope_id": "", - }, - { - "profile_type": "project_owner", - "scope_type": "project", - "scope_id": "project-beta", - }, - {"profile_type": "project_owner", "scope_type": 7, "scope_id": "bad"}, - "not-a-profile", - ], - }, - ) + actor = legacy_actor("legacy-repeat") async with db_session.get_session_factory()() as session: + await ActorService(session).resolve_verified_actor( + verified_token("legacy-repeat"), + request_id=uuid4(), + correlation_id=uuid4(), + ) service = ActorService(session) - await service.register_actor(actor) - await service.register_actor(actor) + first = await service.activate_legacy_workflow_eligibility( + actor, + LegacyWorkflowEligibilityActivationRequest(skill_tags=["stem"]), + ) + changed = await service.activate_legacy_workflow_eligibility( + actor, + LegacyWorkflowEligibilityActivationRequest(skill_tags=["data"]), + ) + unchanged = await service.activate_legacy_workflow_eligibility( + actor, + LegacyWorkflowEligibilityActivationRequest(skill_tags=["data"]), + ) + assert first.id == changed.id == unchanged.id + assert first.skill_tags == ["stem"] + assert changed.skill_tags == unchanged.skill_tags == ["data"] async with db_session.get_session_factory()() as session: - identity = await session.scalar( - select(ActorIdentity).where(ActorIdentity.actor_id == actor.actor_id) - ) - profiles = ( - await session.execute( - select(ActorProfile).where( - ActorProfile.actor_id == actor.actor_id, - ActorProfile.profile_type == "project_owner", + assert ( + await session.scalar( + select(func.count()).select_from(LegacyWorkflowEligibility).where( + LegacyWorkflowEligibility.actor_id == actor.actor_id ) ) - ).scalars().all() - audit_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_type == "actor_profile", + == 1 + ) + assert ( + await session.scalar( + select(func.count()).select_from(AuditEvent).where( + AuditEvent.entity_type == "legacy_workflow_eligibility", AuditEvent.actor_id == actor.actor_id, - AuditEvent.event_type == "actor_profile_observed", ) ) - ).scalars().all() - - assert identity is not None - assert identity.last_claim_snapshot["workstream_relationship_profiles"] == [ - { - "profile_type": "project_owner", - "scope_type": "project", - "scope_id": "project-alpha", - }, - { - "profile_type": "project_owner", - "scope_type": "project", - "scope_id": "project-beta", - }, - ] - assert { - (profile.scope_type, profile.scope_id, profile.status) - for profile in profiles - } == { - ("project", "project-alpha", "observed"), - ("project", "project-beta", "observed"), - } - project_owner_events = [ - event - for event in audit_events - if event.event_payload["profile_type"] == "project_owner" - ] - assert len(project_owner_events) == 2 - assert { - (event.event_payload["scope_type"], event.event_payload["scope_id"]) - for event in project_owner_events - } == {("project", "project-alpha"), ("project", "project-beta")} - - -async def test_missing_relationship_profile_forces_registry_refresh( + == 2 + ) + + +async def test_existing_actor_and_legacy_negative_states_fail_closed( actor_database_env: str, ) -> None: - actor = actor_context( - subject="missing-relationship-profile", - roles=("project_manager",), - claim_snapshot={ - "roles": ["project_manager"], - "workstream_relationship_profiles": [ - { - "profile_type": "project_owner", - "scope_type": "project", - "scope_id": "project-recreated", - } - ], - }, - ) + """Exercise revoked, deactivated, service, and compatibility state branches.""" + human_token = verified_token("revoked-human") async with db_session.get_session_factory()() as session: - service = ActorService(session) - await service.register_actor(actor) + resolved = await ActorService(session).resolve_verified_actor( + human_token, + request_id=uuid4(), + correlation_id=uuid4(), + ) + resolved.identity_link.status = "revoked" + resolved.identity_link.revoked_by = resolved.profile.id + resolved.identity_link.revoked_at = func.now() + resolved.identity_link.revoked_reason = "security response" + await session.commit() + async with db_session.get_session_factory()() as session: + with pytest.raises(IdentityLinkRevoked): + await ActorService(session).find_verified_actor(human_token) + deactivated_token = verified_token("deactivated-human") async with db_session.get_session_factory()() as session: - await session.execute( - delete(ActorProfile).where( - ActorProfile.actor_id == actor.actor_id, - ActorProfile.profile_type == "project_owner", - ) + deactivated = await ActorService(session).resolve_verified_actor( + deactivated_token, + request_id=uuid4(), + correlation_id=uuid4(), ) + deactivated.profile.status = "deactivated" + deactivated.profile.deactivated_by = deactivated.profile.id + deactivated.profile.deactivated_at = func.now() + deactivated.profile.deactivation_reason = "operator decision" await session.commit() + with pytest.raises(ActorDeactivated): + await ActorService(session).update_self( + deactivated, + ActorProfileUpdateRequest(display_name="Blocked"), + ) + service_token = verified_token("known-service", kind="service") + service_actor_id = actor_id_from_external_identity(ISSUER, service_token.subject) async with db_session.get_session_factory()() as session: - service = ActorService(session) - await service.register_actor(actor) + session.add_all( + [ + ActorProfile( + id=service_actor_id, + actor_kind="service", + status="active", + provisioning_method="manual_service_provisioning", + created_by="workstream:system:test", + ), + ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=service_actor_id, + issuer=ISSUER, + subject=service_token.subject, + subject_kind="service", + status="active", + linked_by="workstream:system:test", + ), + ] + ) + await session.commit() + resolved_service = await ActorService(session).resolve_verified_actor( + service_token, + request_id=uuid4(), + correlation_id=uuid4(), + ) + with pytest.raises(UnsupportedSubjectKind): + ActorService.self_response(resolved_service.profile) + legacy = legacy_actor("disabled-legacy") async with db_session.get_session_factory()() as session: - profiles = ( - await session.execute( - select(ActorProfile).where( - ActorProfile.actor_id == actor.actor_id, - ActorProfile.profile_type == "project_owner", - ) - ) - ).scalars().all() - audit_events = ( - await session.execute( - select(AuditEvent).where( - AuditEvent.entity_type == "actor_profile", - AuditEvent.actor_id == actor.actor_id, - AuditEvent.event_type == "actor_profile_observed", - ) + session.add_all( + [ + LegacyActorIdentity( + actor_id=legacy.actor_id, + external_subject=legacy.external_subject, + external_issuer=legacy.external_issuer, + last_seen_roles=["worker"], + last_claim_snapshot={}, + auth_source="dev_mock", + is_dev_auth=True, + ), + LegacyWorkflowEligibility( + id=str(uuid4()), + actor_id=legacy.actor_id, + profile_type="worker", + status="disabled", + skill_tags=[], + scope_type="global", + scope_id="global", + profile_metadata={}, + ), + ] + ) + await session.commit() + with pytest.raises(ActorProfileDisabled): + await ActorService(session).activate_legacy_workflow_eligibility( + legacy, + LegacyWorkflowEligibilityActivationRequest(skill_tags=[]), ) - ).scalars().all() - - assert len(profiles) == 1 - assert profiles[0].scope_type == "project" - assert profiles[0].scope_id == "project-recreated" - assert profiles[0].status == "observed" - project_owner_events = [ - event - for event in audit_events - if event.event_payload["profile_type"] == "project_owner" - ] - assert len(project_owner_events) == 2 - - -async def test_active_profile_without_matching_token_role_cannot_use_worker_profile_api( - actor_client: AsyncClient, - monkeypatch: pytest.MonkeyPatch, -) -> None: - set_dev_actor(monkeypatch, roles="worker", subject="role-lost-worker") - created = await actor_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={"skill_tags": ["stem"]}, - ) - assert created.status_code == 200, created.text - - set_dev_actor(monkeypatch, roles="project_manager", subject="role-lost-worker") - denied = await actor_client.post( - "/api/v1/workers/me/profile", - headers=auth_headers(), - json={"skill_tags": ["stem"]}, - ) - - assert denied.status_code == 403 + compatibility = LegacyWorkflowEligibilityCompatibility(session) + assert await compatibility.get_active_submitter_eligibility(legacy.actor_id) is None + assert await compatibility.get_active_submitter_eligibility(str(uuid4())) is None diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index 3f0ef6d8..df441fc3 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -3,6 +3,7 @@ import asyncio from concurrent.futures import ThreadPoolExecutor import json +import os from pathlib import Path import threading import time @@ -16,6 +17,23 @@ from sqlalchemy.ext.asyncio import create_async_engine from app.adapters.auth.dev import actor_id_from_external_identity +from app.modules.actors.legacy_classification import ( + CLASSIFICATION_FILE_ENV, + LegacyActorClassification, + LegacyActorClassificationManifest, + LegacyActorRow, + LegacyClassificationError, + build_envelope, + canonical_envelope_bytes, + database_binding_identifier, +) + + +def _alembic_config() -> Config: + project_root = Path(__file__).resolve().parents[1] + config = Config(str(project_root / "alembic.ini")) + config.set_main_option("script_location", str(project_root / "alembic")) + return config def test_alembic_upgrade_and_downgrade(isolated_database_env: str, migration_lock) -> None: @@ -165,7 +183,7 @@ def test_post_submit_policy_upgrade_blocks_pre_provenance_runtime_rows( assert columns_exist is False -def test_actor_profile_registry_removes_obsolete_profile_tables( +def test_canonical_actor_registry_separates_authority_from_legacy_workflow_metadata( isolated_database_env: str, migration_lock, ) -> None: @@ -182,12 +200,168 @@ def test_actor_profile_registry_removes_obsolete_profile_tables( finally: command.downgrade(config, "base") - assert "actor_identities" in table_names assert "actor_profiles" in table_names + assert "actor_identity_links" in table_names + assert "legacy_actor_identities" in table_names + assert "legacy_workflow_eligibility" in table_names + assert "actor_identities" not in table_names assert "worker_profiles" not in table_names assert "reviewer_profiles" not in table_names +def test_canonical_actor_upgrade_rejects_unclassified_legacy_rows( + isolated_database_env: str, + migration_lock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Fail closed before changing tables when non-empty legacy data is ambiguous.""" + config = _alembic_config() + actor_id = actor_id_from_external_identity( + "https://identity.test", + "unclassified-human", + ) + monkeypatch.delenv(CLASSIFICATION_FILE_ENV, raising=False) + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0019_authority_idempotency") + asyncio.run( + _seed_pre_0020_actor( + isolated_database_env, + actor_id=actor_id, + subject="unclassified-human", + ) + ) + + with pytest.raises( + LegacyClassificationError, + match="^classification_file_not_configured$", + ): + command.upgrade(config, "0020_canonical_actor_profile") + + state = asyncio.run(_pre_0020_actor_state(isolated_database_env, actor_id)) + finally: + command.downgrade(config, "base") + + assert state == {"revision": "0019_authority_idempotency", "legacy_rows": 1} + + +def test_canonical_actor_upgrade_redacts_invalid_legacy_row_values( + isolated_database_env: str, + migration_lock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep invalid source identity values out of migration diagnostics.""" + config = _alembic_config() + raw_actor_id = "raw-private-invalid-actor-id" + monkeypatch.delenv(CLASSIFICATION_FILE_ENV, raising=False) + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0019_authority_idempotency") + asyncio.run( + _seed_pre_0020_actor( + isolated_database_env, + actor_id=raw_actor_id, + subject="raw-private-subject", + ) + ) + with pytest.raises(LegacyClassificationError) as captured: + command.upgrade(config, "0020_canonical_actor_profile") + assert str(captured.value) == "invalid_source_rows" + assert raw_actor_id not in str(captured.value) + state = asyncio.run(_pre_0020_actor_state(isolated_database_env, raw_actor_id)) + finally: + command.downgrade(config, "base") + + assert state == {"revision": "0019_authority_idempotency", "legacy_rows": 1} + + +def test_canonical_actor_classified_upgrade_preserves_identity_and_attribution( + isolated_database_env: str, + migration_lock, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Consume bound evidence once and downgrade later without the external file.""" + config = _alembic_config() + issuer = "https://identity.test" + subject = "classified-human" + actor_id = actor_id_from_external_identity(issuer, subject) + audit_event_id = str(uuid4()) + envelope_path = tmp_path / "classification-envelope.json" + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "0019_authority_idempotency") + asyncio.run( + _seed_pre_0020_actor( + isolated_database_env, + actor_id=actor_id, + subject=subject, + audit_event_id=audit_event_id, + ) + ) + binding = asyncio.run(_legacy_database_binding(isolated_database_env)) + row = LegacyActorRow(actor_id=actor_id, issuer=issuer, subject=subject) + envelope = build_envelope( + LegacyActorClassificationManifest( + schema_version=1, + classifications=( + LegacyActorClassification( + actor_id=actor_id, + issuer=issuer, + subject=subject, + subject_kind="human", + ), + ), + ), + (row,), + database_binding=binding, + generated_at="2026-07-15T12:00:00Z", + ) + envelope_path.write_bytes(canonical_envelope_bytes(envelope)) + os.chmod(envelope_path, 0o600) + monkeypatch.setenv(CLASSIFICATION_FILE_ENV, str(envelope_path)) + + command.upgrade(config, "0020_canonical_actor_profile") + upgraded = asyncio.run( + _canonical_actor_migration_state( + isolated_database_env, + actor_id, + audit_event_id, + ) + ) + envelope_path.unlink() + monkeypatch.delenv(CLASSIFICATION_FILE_ENV, raising=False) + command.downgrade(config, "0019_authority_idempotency") + restored = asyncio.run(_pre_0020_actor_state(isolated_database_env, actor_id)) + with pytest.raises( + LegacyClassificationError, + match="^classification_file_not_configured$", + ): + command.upgrade(config, "0020_canonical_actor_profile") + reupgrade_rejected = asyncio.run( + _pre_0020_actor_state(isolated_database_env, actor_id) + ) + finally: + command.downgrade(config, "base") + + assert upgraded == { + "profile_id": actor_id, + "actor_kind": "human", + "display_name": None, + "contact_email": None, + "identity_subject": subject, + "legacy_profile_type": "worker", + "audit_actor_id": actor_id, + "classified_count": 1, + "source_checksum": envelope.source_row_set_sha256, + } + assert restored == {"revision": "0019_authority_idempotency", "legacy_rows": 1} + assert reupgrade_rejected == restored + + def test_actor_profile_registry_unique_constraints_are_enforced( isolated_database_env: str, migration_lock, @@ -206,6 +380,48 @@ def test_actor_profile_registry_unique_constraints_are_enforced( command.downgrade(config, "base") +def test_canonical_actor_downgrade_refuses_nonactive_authority_state( + isolated_database_env: str, + migration_lock, +) -> None: + """Prevent rollback from silently restoring revoked or inactive actors.""" + config = _alembic_config() + actor_id = actor_id_from_external_identity("https://identity.test", "rollback-guard") + with migration_lock(): + try: + command.downgrade(config, "base") + command.upgrade(config, "head") + asyncio.run( + _seed_canonical_actor_for_downgrade_guard( + isolated_database_env, actor_id + ) + ) + for state in ("revoked", "suspended", "deactivated"): + asyncio.run( + _set_canonical_actor_guard_state( + isolated_database_env, actor_id, state + ) + ) + with pytest.raises( + RuntimeError, + match="^canonical actor downgrade refused: inactive authority state$", + ): + command.downgrade(config, "0019_authority_idempotency") + assert asyncio.run(_current_revision(isolated_database_env)) == ( + "0020_canonical_actor_profile" + ) + asyncio.run( + _reset_canonical_actor_guard_state( + isolated_database_env, + actor_id, + owner_reset=state == "deactivated", + ) + ) + command.downgrade(config, "0019_authority_idempotency") + finally: + command.downgrade(config, "base") + + def test_artifact_foundation_upgrade_preserves_prior_head_and_promotes_nothing( isolated_database_env: str, migration_lock, @@ -646,178 +862,533 @@ async def _fetch_table_names(database_url: str) -> set[str]: await engine.dispose() -async def _assert_actor_registry_unique_constraints(database_url: str) -> None: - """Insert duplicates and prove actor registry unique constraints reject them.""" +async def _seed_pre_0020_actor( + database_url: str, + *, + actor_id: str, + subject: str, + audit_event_id: str | None = None, +) -> None: + """Seed one valid legacy identity, typed profile, and optional attribution.""" engine = create_async_engine(database_url) - actor_id = actor_id_from_external_identity("flow-test", "unique-actor") try: async with engine.begin() as connection: await connection.execute( text( - """ - insert into actor_identities ( - actor_id, - external_subject, - external_issuer, - display_name, - email, - last_seen_roles, - last_claim_snapshot, - auth_source, - is_dev_auth + "insert into actor_identities " + "(actor_id,external_subject,external_issuer,display_name,email," + "last_seen_roles,last_claim_snapshot,auth_source,is_dev_auth) values " + "(:actor,:subject,'https://identity.test','Legacy Human'," + "'legacy@example.test','[\"worker\"]'::json,'{}'::json,'flow',false)" + ), + {"actor": actor_id, "subject": subject}, + ) + await connection.execute( + text( + "insert into actor_profiles " + "(id,actor_id,profile_type,status,skill_tags,scope_type,scope_id," + "profile_metadata) values " + "(:id,:actor,'worker','active','[\"stem\"]'::json,'global','global'," + "'{\"source\":\"legacy\"}'::json)" + ), + {"id": str(uuid4()), "actor": actor_id}, + ) + if audit_event_id is not None: + await connection.execute( + text( + "insert into audit_events " + "(id,entity_type,entity_id,event_type,actor_id,external_subject," + "external_issuer,actor_roles,claim_snapshot,auth_source,is_dev_auth," + "reason,event_payload) values " + "(:id,'task','legacy-task','task_created',:actor,:subject," + "'https://identity.test','[\"worker\"]'::json,'{}'::json,'flow'," + "false,'migration attribution proof','{}'::json)" + ), + {"id": audit_event_id, "actor": actor_id, "subject": subject}, + ) + finally: + await engine.dispose() + + +async def _legacy_database_binding(database_url: str) -> str: + """Return the classification binding for the current isolated database.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + database_name, database_oid = ( + await connection.execute( + text( + "select current_database(), oid from pg_database " + "where datname=current_database()" ) - values ( - :actor_id, - 'unique-actor', - 'flow-test', - 'Unique Actor', - 'unique@example.test', - cast(:roles as json), - cast(:claim_snapshot as json), - 'dev_mock', - true + ) + ).one() + return database_binding_identifier(database_name, database_oid) + finally: + await engine.dispose() + + +async def _pre_0020_actor_state(database_url: str, actor_id: str) -> dict[str, object]: + """Return prior-head revision and retained legacy actor count.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + return { + "revision": await connection.scalar( + text("select version_num from alembic_version") + ), + "legacy_rows": await connection.scalar( + text("select count(*) from actor_identities where actor_id=:actor"), + {"actor": actor_id}, + ), + } + finally: + await engine.dispose() + + +async def _canonical_actor_migration_state( + database_url: str, + actor_id: str, + audit_event_id: str, +) -> dict[str, object]: + """Return canonical, compatibility, evidence, and attribution migration facts.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + profile = ( + await connection.execute( + text( + "select id,actor_kind,display_name,contact_email " + "from actor_profiles where id=:actor" + ), + {"actor": actor_id}, + ) + ).one() + identity_subject = await connection.scalar( + text( + "select subject from actor_identity_links " + "where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + legacy_profile_type = await connection.scalar( + text( + "select profile_type from legacy_workflow_eligibility " + "where actor_id=:actor" + ), + {"actor": actor_id}, + ) + audit_actor_id = await connection.scalar( + text("select actor_id from audit_events where id=:event"), + {"event": audit_event_id}, + ) + migration_state = ( + await connection.execute( + text( + "select classified_count,source_row_set_sha256 " + "from actor_profile_migration_state where id=1" ) - """ + ) + ).one() + return { + "profile_id": profile.id, + "actor_kind": profile.actor_kind, + "display_name": profile.display_name, + "contact_email": profile.contact_email, + "identity_subject": identity_subject, + "legacy_profile_type": legacy_profile_type, + "audit_actor_id": audit_actor_id, + "classified_count": migration_state.classified_count, + "source_checksum": migration_state.source_row_set_sha256, + } + finally: + await engine.dispose() + + +async def _current_revision(database_url: str) -> str: + """Return the exact current Alembic revision.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + return str(await connection.scalar(text("select version_num from alembic_version"))) + finally: + await engine.dispose() + + +async def _seed_canonical_actor_for_downgrade_guard( + database_url: str, + actor_id: str, +) -> None: + """Seed one complete active canonical actor for rollback guard tests.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await _insert_canonical_actor(connection, actor_id, "rollback-guard", "human") + finally: + await engine.dispose() + + +async def _set_canonical_actor_guard_state( + database_url: str, + actor_id: str, + state: str, +) -> None: + """Put one actor in a reviewed rollback stop state.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + if state == "revoked": + await connection.execute( + text( + "update actor_identity_links set status='revoked', " + "revoked_by=:actor, revoked_at=now(), revoked_reason='test guard' " + "where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + elif state == "suspended": + await connection.execute( + text( + "update actor_profiles set status='suspended', suspended_by=:actor, " + "suspended_at=now(), suspension_reason='test guard' where id=:actor" + ), + {"actor": actor_id}, + ) + elif state == "deactivated": + await connection.execute( + text( + "update actor_profiles set status='deactivated', deactivated_by=:actor, " + "deactivated_at=now(), deactivation_reason='test guard' where id=:actor" + ), + {"actor": actor_id}, + ) + else: + raise AssertionError(f"unknown test state: {state}") + finally: + await engine.dispose() + + +async def _reset_canonical_actor_guard_state( + database_url: str, + actor_id: str, + *, + owner_reset: bool, +) -> None: + """Restore test-owned state after proving the migration refuses it.""" + engine = create_async_engine(database_url) + try: + if owner_reset: + async with engine.begin() as connection: + await connection.execute( + text( + "alter table actor_profiles disable trigger actor_profile_history_guard" + ) + ) + async with engine.begin() as connection: + await connection.execute( + text( + "update actor_profiles set status='active', suspended_by=null, " + "suspended_at=null, suspension_reason=null, deactivated_by=null, " + "deactivated_at=null, deactivation_reason=null where id=:actor" ), - { - "actor_id": actor_id, - "roles": json.dumps(["worker"]), - "claim_snapshot": json.dumps({"roles": ["worker"]}), - }, + {"actor": actor_id}, ) await connection.execute( text( - """ - insert into actor_profiles ( - id, - actor_id, - profile_type, - status, - skill_tags, - scope_type, - scope_id, - profile_metadata + "update actor_identity_links set status='active', revoked_by=null, " + "revoked_at=null, revoked_reason=null where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + if owner_reset: + async with engine.begin() as connection: + await connection.execute( + text( + "alter table actor_profiles enable trigger actor_profile_history_guard" ) - values ( - :id, - :actor_id, - 'worker', - 'observed', - cast(:skill_tags as json), - 'global', - 'global', - cast(:profile_metadata as json) + ) + finally: + await engine.dispose() + + +async def _assert_actor_registry_unique_constraints(database_url: str) -> None: + """Prove canonical indexes, constraints, timestamps, and history guards.""" + engine = create_async_engine(database_url) + actor_id = actor_id_from_external_identity("https://identity.test", "unique-actor") + try: + async with engine.begin() as connection: + await _insert_canonical_actor(connection, actor_id, "unique-actor", "human") + index_rows = ( + await connection.execute( + text( + "select indexname,indexdef from pg_indexes " + "where schemaname=current_schema() and " + "tablename in ('actor_profiles','actor_identity_links')" ) - """ + ) + ).all() + indexes = {row.indexname: row.indexdef for row in index_rows} + assert "(status, actor_kind)" in indexes[ + "ix_actor_profiles_status_actor_kind" + ] + assert "(last_seen_at)" in indexes["ix_actor_profiles_last_seen_at"] + assert "(issuer, subject, status)" in indexes[ + "ix_actor_identity_links_issuer_subject_status" + ] + assert "ix_actor_profiles_actor_kind" not in indexes + assert "ix_actor_profiles_status" not in indexes + assert "ix_actor_identity_links_status" not in indexes + timestamps = ( + await connection.execute( + text( + "select p.created_at,p.updated_at,l.linked_at,l.last_verified_at " + "from actor_profiles p join actor_identity_links l " + "on l.actor_profile_id=p.id where p.id=:actor" + ), + {"actor": actor_id}, + ) + ).one() + assert all(value is not None and value.tzinfo is not None for value in timestamps) + + await _expect_integrity_error( + engine, + text( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,created_by) values " + "(:actor,'human','active','automatic_first_access',:actor)" + ), + {"actor": actor_id}, + ) + await _expect_integrity_error( + engine, + text( + "insert into actor_identity_links " + "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by) values " + "(:id,:actor,'https://identity.test','second-link','human','active',:actor)" + ), + {"id": str(uuid4()), "actor": actor_id}, + ) + + invalid_profiles = ( + ("not-a-uuid", "human", "active", "automatic_first_access", {}), + (str(uuid4()), "agent", "active", "automatic_first_access", {}), + (str(uuid4()), "human", "unknown", "automatic_first_access", {}), + (str(uuid4()), "human", "active", "manual_service_provisioning", {}), + ( + str(uuid4()), + "human", + "suspended", + "automatic_first_access", + {}, + ), + ) + for invalid_id, kind, status, method, lifecycle in invalid_profiles: + await _expect_integrity_error( + engine, + text( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,created_by) values " + "(:id,:kind,:status,:method,:id)" ), { - "id": str(uuid4()), - "actor_id": actor_id, - "skill_tags": json.dumps([]), - "profile_metadata": json.dumps({}), + "id": invalid_id, + "kind": kind, + "status": status, + "method": method, + **lifecycle, }, ) - duplicate_actor_id = text( - """ - insert into actor_identities ( - actor_id, - external_subject, - external_issuer, - last_seen_roles, - last_claim_snapshot, - auth_source, - is_dev_auth - ) - values ( - :actor_id, - 'different-subject', - 'flow-test', - cast(:roles as json), - cast(:claim_snapshot as json), - 'dev_mock', - true - ) - """ + invalid_links = ( + {"link_id": "not-a-uuid"}, + {"issuer": " "}, + {"link_subject": " "}, + {"subject_kind": "agent"}, + {"status": "unknown"}, + {"status": "revoked"}, ) - duplicate_external_identity = text( - """ - insert into actor_identities ( - actor_id, - external_subject, - external_issuer, - last_seen_roles, - last_claim_snapshot, - auth_source, - is_dev_auth + for position, overrides in enumerate(invalid_links): + await _expect_invalid_canonical_pair( + engine, + subject=f"invalid-link-{position}", + **overrides, ) - values ( - :actor_id, - 'unique-actor', - 'flow-test', - cast(:roles as json), - cast(:claim_snapshot as json), - 'dev_mock', - true - ) - """ + + await _expect_dbapi_error( + engine, + text("update actor_profiles set actor_kind='service' where id=:actor"), + {"actor": actor_id}, ) - duplicate_profile_scope = text( - """ - insert into actor_profiles ( - id, - actor_id, - profile_type, - status, - skill_tags, - scope_type, - scope_id, - profile_metadata - ) - values ( - :id, - :actor_id, - 'worker', - 'observed', - cast(:skill_tags as json), - 'global', - 'global', - cast(:profile_metadata as json) - ) - """ + await _expect_dbapi_error( + engine, + text( + "update actor_identity_links set subject='changed' " + "where actor_profile_id=:actor" + ), + {"actor": actor_id}, ) - await _expect_integrity_error( + await _expect_dbapi_error( engine, - duplicate_actor_id, - { - "actor_id": actor_id, - "roles": json.dumps([]), - "claim_snapshot": json.dumps({}), - }, + text("delete from actor_profiles where id=:actor"), + {"actor": actor_id}, ) - await _expect_integrity_error( + await _expect_dbapi_error( engine, - duplicate_external_identity, - { - "actor_id": actor_id_from_external_identity("flow-test", "other-unique-actor"), - "roles": json.dumps([]), - "claim_snapshot": json.dumps({}), - }, + text("delete from actor_identity_links where actor_profile_id=:actor"), + {"actor": actor_id}, + ) + orphan_id = actor_id_from_external_identity( + "https://identity.test", "orphan-profile" ) await _expect_integrity_error( engine, - duplicate_profile_scope, - { - "id": str(uuid4()), - "actor_id": actor_id, - "skill_tags": json.dumps([]), - "profile_metadata": json.dumps({}), - }, + text( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,created_by) values " + "(:actor,'human','active','automatic_first_access',:actor)" + ), + {"actor": orphan_id}, ) + + connection = await engine.connect() + transaction = await connection.begin() + try: + await connection.execute( + text( + "update actor_profiles set status='deactivated', " + "deactivated_by=:actor,deactivated_at=now()," + "deactivation_reason='terminal proof' where id=:actor" + ), + {"actor": actor_id}, + ) + with pytest.raises(DBAPIError): + await connection.execute( + text( + "update actor_profiles set status='active',deactivated_by=null," + "deactivated_at=null,deactivation_reason=null where id=:actor" + ), + {"actor": actor_id}, + ) + finally: + await transaction.rollback() + await connection.close() + + width_actor_id = actor_id_from_external_identity( + "https://identity.test", "s" * 200 + ) + async with engine.begin() as connection: + await _insert_canonical_actor(connection, width_actor_id, "s" * 200, "human") + oversized_actor_id = actor_id_from_external_identity( + "https://identity.test", "s" * 201 + ) + with pytest.raises(DBAPIError): + async with engine.begin() as connection: + await _insert_canonical_actor( + connection, oversized_actor_id, "s" * 201, "human" + ) + + other_actor_id = actor_id_from_external_identity( + "https://identity.test", "other-unique-actor" + ) + with pytest.raises(IntegrityError): + async with engine.begin() as connection: + await _insert_canonical_actor( + connection, + other_actor_id, + "unique-actor", + "human", + ) + + mismatched_actor_id = actor_id_from_external_identity( + "https://identity.test", "kind-mismatch" + ) + with pytest.raises(IntegrityError): + async with engine.begin() as connection: + await _insert_canonical_actor( + connection, + mismatched_actor_id, + "kind-mismatch", + "service", + link_kind="human", + ) finally: await engine.dispose() +async def _insert_canonical_actor( + connection, + actor_id: str, + subject: str, + actor_kind: str, + *, + link_kind: str | None = None, +) -> None: + """Insert a complete profile-link pair in one deferred-constraint transaction.""" + provisioning = ( + "automatic_first_access" + if actor_kind == "human" + else "manual_service_provisioning" + ) + await connection.execute( + text( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,created_by) values " + "(:actor,:kind,'active',:provisioning,:actor)" + ), + {"actor": actor_id, "kind": actor_kind, "provisioning": provisioning}, + ) + await connection.execute( + text( + "insert into actor_identity_links " + "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by) values " + "(:id,:actor,'https://identity.test',:subject,:kind,'active',:actor)" + ), + { + "id": str(uuid4()), + "actor": actor_id, + "subject": subject, + "kind": link_kind or actor_kind, + }, + ) + + +async def _expect_invalid_canonical_pair( + engine, + *, + subject: str, + link_id: str | None = None, + issuer: str = "https://identity.test", + link_subject: str | None = None, + subject_kind: str = "human", + status: str = "active", +) -> None: + """Assert that a malformed identity link cannot commit with its profile.""" + actor_id = actor_id_from_external_identity("https://identity.test", subject) + with pytest.raises(IntegrityError): + async with engine.begin() as connection: + await connection.execute( + text( + "insert into actor_profiles " + "(id,actor_kind,status,provisioning_method,created_by) values " + "(:actor,'human','active','automatic_first_access',:actor)" + ), + {"actor": actor_id}, + ) + await connection.execute( + text( + "insert into actor_identity_links " + "(id,actor_profile_id,issuer,subject,subject_kind,status,linked_by) " + "values (:id,:actor,:issuer,:subject,:kind,:status,:actor)" + ), + { + "id": link_id or str(uuid4()), + "actor": actor_id, + "issuer": issuer, + "subject": subject if link_subject is None else link_subject, + "kind": subject_kind, + "status": status, + }, + ) async def _expect_integrity_error(engine, statement, params: dict) -> None: """Assert that one SQL statement raises a database integrity error.""" with pytest.raises(IntegrityError): @@ -825,6 +1396,13 @@ async def _expect_integrity_error(engine, statement, params: dict) -> None: await connection.execute(statement, params) +async def _expect_dbapi_error(engine, statement, params: dict) -> None: + """Assert that one statement is rejected by a database trigger or constraint.""" + with pytest.raises(DBAPIError): + async with engine.begin() as connection: + await connection.execute(statement, params) + + async def _seed_pre_provenance_post_submit_policy( database_url: str, project_id: str, diff --git a/backend/tests/test_api_rate_controls.py b/backend/tests/test_api_rate_controls.py index d2773faa..2b769087 100644 --- a/backend/tests/test_api_rate_controls.py +++ b/backend/tests/test_api_rate_controls.py @@ -706,16 +706,6 @@ async def admin_mutation() -> dict[str, bool]: ("settings", "subject", "service"), [ (Settings(environment="test"), RATE_SUBJECT, RateControlService()), - ( - Settings(environment="test", api_rate_limit_key_secret=RATE_SECRET_TEXT), - "x" * 4_097, - RateControlService(), - ), - ( - Settings(environment="test", api_rate_limit_key_secret=RATE_SECRET_TEXT), - "\ud800", - RateControlService(), - ), ( Settings(environment="test", api_rate_limit_key_secret=RATE_SECRET_TEXT), RATE_SUBJECT, diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index b5a38e3e..e80f04fe 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import asyncpg import base64 import hashlib import hmac @@ -48,6 +49,23 @@ def _application_paths(app) -> set[str]: return paths +def test_legacy_submitter_eligibility_adapter_has_a_shrinking_static_allowlist() -> None: + """Keep the temporary bridge confined to its owner and three intake gates.""" + app_root = Path(__file__).resolve().parents[1] / "app" + consumers = { + path.relative_to(app_root).as_posix() + for path in app_root.rglob("*.py") + if "LegacyWorkflowEligibilityCompatibility" in path.read_text() + } + task_service = (app_root / "modules/tasks/service.py").read_text() + + assert consumers == { + "modules/actors/service.py", + "modules/tasks/service.py", + } + assert task_service.count("_require_legacy_submitter_eligibility(actor)") == 3 + + @pytest.fixture(autouse=True) def clear_settings_cache() -> None: get_settings.cache_clear() @@ -57,6 +75,29 @@ def clear_settings_cache() -> None: clear_auth_verifier_cache() +async def clear_test_audit_events(database_url: str) -> None: + """Reset append-only evidence under explicit isolated-test ownership.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + async with connection.transaction(): + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_update_delete" + ) + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_truncate" + ) + await connection.execute("truncate table audit_events cascade") + await connection.execute("truncate table api_rate_control_counters") + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_truncate" + ) + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_update_delete" + ) + finally: + await connection.close() + + @pytest.fixture def auth_database_env( monkeypatch: pytest.MonkeyPatch, @@ -65,6 +106,10 @@ def auth_database_env( ) -> Iterator[str]: """Run auth route persistence tests against a migrated Postgres schema.""" monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) + monkeypatch.setenv( + "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ) get_settings.cache_clear() asyncio.run(db_session.dispose_engine()) @@ -75,6 +120,8 @@ def auth_database_env( command.downgrade(config, "base") command.upgrade(config, "head") yield postgres_database_url + asyncio.run(clear_test_audit_events(postgres_database_url)) + asyncio.run(db_session.dispose_engine()) command.downgrade(config, "base") asyncio.run(db_session.dispose_engine()) get_settings.cache_clear() @@ -233,6 +280,36 @@ async def test_local_hmac_fixture_uses_final_claim_shape() -> None: assert result.legacy.roles == ("reviewer",) +async def test_flow_auth_rejects_subject_above_persisted_identity_bound() -> None: + secret = "local-test-secret" + now = int(datetime.now(UTC).timestamp()) + verifier = FlowAuthVerifier( + Settings( + environment="test", + auth_provider="flow", + flow_auth_issuer="https://issuer.local.test", + flow_auth_audience="workstream", + flow_auth_local_hmac_secret=secret, + ) + ) + token = issue_local_hmac_token( + secret, + { + "iss": "https://issuer.local.test", + "sub": "s" * 201, + "aud": "workstream", + "exp": now + 300, + "iat": now, + "jti": "oversized-subject", + "subject_kind": "human", + "scope": "workstream:access", + }, + ) + + with pytest.raises(AuthVerificationError, match="token subject is required"): + await verifier.verify(token) + + @pytest.mark.parametrize("environment", ["staging", "preview", "prod", "production"]) def test_local_hmac_fixture_is_impossible_in_production(environment: str) -> None: with pytest.raises(RuntimeError, match="cannot run outside local/test"): @@ -731,7 +808,7 @@ async def test_service_and_agent_tokens_receive_no_legacy_authority( assert agent.legacy is None assert space.legacy is None with pytest.raises(HTTPException) as exc_info: - await get_registered_actor(agent, None) # type: ignore[arg-type] + await get_registered_actor(agent, None, None) # type: ignore[arg-type] assert exc_info.value.status_code == 403 assert exc_info.value.error_code == "unsupported_subject_kind" @@ -1019,6 +1096,7 @@ def build_client(**kwargs: Any) -> AsyncClient: assert clients["jwks"][0].is_closed assert clients["introspection"][0].is_closed + @pytest.mark.parametrize( "response_override", [ @@ -1209,6 +1287,7 @@ def test_legacy_compatibility_dependency_has_fixed_consumer_allowlist() -> None: "adapters/auth/flow.py", "api/deps/api_controls.py", "api/deps/auth.py", + "api/deps/rate_controls.py", "core/auth.py", "interfaces/auth.py", "schemas/auth.py", @@ -1342,10 +1421,10 @@ async def test_auth_me_maps_actor_registry_failure_to_service_unavailable( monkeypatch.setenv("WORKSTREAM_DEV_AUTH_ROLES", "worker") get_settings.cache_clear() - async def fail_register_actor(self, actor): + async def fail_resolve_actor(self, token, *, request_id, correlation_id): raise SQLAlchemyError("registry unavailable") - monkeypatch.setattr(ActorService, "register_actor", fail_register_actor) + monkeypatch.setattr(ActorService, "resolve_verified_actor", fail_resolve_actor) app = create_app() async with AsyncClient( diff --git a/backend/tests/test_checkers.py b/backend/tests/test_checkers.py index 0a45cd02..a9e1c69a 100644 --- a/backend/tests/test_checkers.py +++ b/backend/tests/test_checkers.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import asyncpg from collections.abc import AsyncIterator, Iterator from pathlib import Path from typing import Any @@ -57,6 +58,29 @@ ) +async def clear_test_audit_events(database_url: str) -> None: + """Reset append-only evidence under explicit isolated-test ownership.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + async with connection.transaction(): + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_update_delete" + ) + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_truncate" + ) + await connection.execute("truncate table audit_events cascade") + await connection.execute("truncate table api_rate_control_counters") + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_truncate" + ) + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_update_delete" + ) + finally: + await connection.close() + + @pytest.fixture def checker_database_env( monkeypatch: pytest.MonkeyPatch, @@ -65,6 +89,10 @@ def checker_database_env( ) -> Iterator[str]: monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") + monkeypatch.setenv( + "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ) set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") get_settings.cache_clear() asyncio.run(db_session.dispose_engine()) @@ -74,6 +102,8 @@ def checker_database_env( command.downgrade(config, "base") command.upgrade(config, "head") yield postgres_database_url + asyncio.run(clear_test_audit_events(postgres_database_url)) + asyncio.run(db_session.dispose_engine()) command.downgrade(config, "base") asyncio.run(db_session.dispose_engine()) get_settings.cache_clear() diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 1b01d554..8f77acc5 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import asyncpg import hashlib import inspect import json @@ -32,7 +33,7 @@ from app.db import session as db_session from app.db.base import Base from app.main import create_app -from app.modules.actors.models import ActorIdentity, ActorProfile +from app.modules.actors.models import ActorIdentityLink, ActorProfile, LegacyActorIdentity from app.interfaces.project_agents import ( GuideSourceMaterial, GuideSufficiencyAgentResult, @@ -73,12 +74,37 @@ ProjectService, StaleProjectSetupContinuation, ) + + from app.modules.projects.post_submit_policy import ( build_project_post_submit_checker_spec, compile_project_post_submit_checker_spec, ) +async def clear_test_audit_events(database_url: str) -> None: + """Reset append-only evidence under explicit isolated-test ownership.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + async with connection.transaction(): + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_update_delete" + ) + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_truncate" + ) + await connection.execute("truncate table audit_events cascade") + await connection.execute("truncate table api_rate_control_counters") + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_truncate" + ) + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_update_delete" + ) + finally: + await connection.close() + + @pytest.fixture def project_database_env( monkeypatch: pytest.MonkeyPatch, @@ -86,6 +112,10 @@ def project_database_env( migration_lock, ) -> Iterator[str]: monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) + monkeypatch.setenv( + "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ) monkeypatch.setenv("WORKSTREAM_AUTH_PROVIDER", "dev") monkeypatch.setenv("WORKSTREAM_ENVIRONMENT", "test") monkeypatch.setenv("WORKSTREAM_DEV_AUTH_TOKEN", "project-token") @@ -104,6 +134,8 @@ def project_database_env( command.downgrade(config, "base") command.upgrade(config, "head") yield postgres_database_url + asyncio.run(clear_test_audit_events(postgres_database_url)) + asyncio.run(db_session.dispose_engine()) command.downgrade(config, "base") asyncio.run(db_session.dispose_engine()) get_settings.cache_clear() @@ -542,20 +574,23 @@ async def test_project_route_registers_project_manager_actor_without_auth_me( assert response.status_code == 201, response.text async with db_session.get_session_factory()() as session: - identity = await session.scalar( - select(ActorIdentity).where( - ActorIdentity.external_subject == "project-manager-subject" + identity_link = await session.scalar( + select(ActorIdentityLink).where( + ActorIdentityLink.subject == "project-manager-subject" ) ) - assert identity is not None - profiles = ( - await session.execute( - select(ActorProfile).where(ActorProfile.actor_id == identity.actor_id) - ) - ).scalars().all() + assert identity_link is not None + profile = await session.get(ActorProfile, identity_link.actor_profile_id) + legacy_identity = await session.get( + LegacyActorIdentity, + identity_link.actor_profile_id, + ) - assert identity.last_seen_roles == ["project_manager"] - assert any(profile.profile_type == "project_manager" for profile in profiles) + assert profile is not None + assert profile.actor_kind == "human" + assert profile.status == "active" + assert legacy_identity is not None + assert legacy_identity.last_seen_roles == ["project_manager"] async def create_guide(client: AsyncClient, project_id: str, payload: dict) -> dict: diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 89f70cd5..ec0bf22d 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import asyncpg import hashlib import json from collections.abc import AsyncIterator, Iterator @@ -28,8 +29,13 @@ from app.db import session as db_session from app.db.base import Base from app.main import create_app -from app.modules.actors.models import ActorIdentity, ActorProfile -from app.modules.actors.schemas import ActorProfileActivationRequest +from app.modules.actors.models import ( + ActorIdentityLink, + ActorProfile, + LegacyActorIdentity, + LegacyWorkflowEligibility, +) +from app.modules.actors.schemas import LegacyWorkflowEligibilityActivationRequest from app.modules.actors.service import ActorService from app.modules.projects.models import ( EffectiveProjectSubmissionArtifactPolicy, @@ -59,6 +65,29 @@ from app.schemas.auth import ActorContext +async def clear_test_audit_events(database_url: str) -> None: + """Reset append-only evidence under explicit isolated-test ownership.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + async with connection.transaction(): + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_update_delete" + ) + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_truncate" + ) + await connection.execute("truncate table audit_events cascade") + await connection.execute("truncate table api_rate_control_counters") + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_truncate" + ) + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_update_delete" + ) + finally: + await connection.close() + + async def test_task_repository_delegates_audit_persistence() -> None: """Keep legacy task audit methods as same-session shared-writer adapters.""" session = MagicMock() @@ -108,6 +137,10 @@ def task_database_env( ) -> Iterator[str]: monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") + monkeypatch.setenv( + "WORKSTREAM_API_RATE_LIMIT_KEY_SECRET", + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + ) set_dev_actor(monkeypatch, roles="project_manager", subject="project-manager-subject") get_settings.cache_clear() asyncio.run(db_session.dispose_engine()) @@ -117,6 +150,8 @@ def task_database_env( command.downgrade(config, "base") command.upgrade(config, "head") yield postgres_database_url + asyncio.run(clear_test_audit_events(postgres_database_url)) + asyncio.run(db_session.dispose_engine()) command.downgrade(config, "base") asyncio.run(db_session.dispose_engine()) get_settings.cache_clear() @@ -182,16 +217,22 @@ def actor_id(subject: str, issuer: str = "flow-test") -> str: return actor_id_from_external_identity(issuer, subject) -async def fetch_actor_registry_rows(subject: str, issuer: str = "flow-test") -> tuple[ActorIdentity | None, list[ActorProfile]]: - """Load actor registry rows for assertions.""" +async def fetch_legacy_actor_rows( + subject: str, + issuer: str = "flow-test", +) -> tuple[LegacyActorIdentity | None, list[LegacyWorkflowEligibility]]: + """Load non-authoritative compatibility rows for assertions.""" expected_actor_id = actor_id(subject, issuer) async with db_session.get_session_factory()() as session: - identity = await session.get(ActorIdentity, expected_actor_id) + identity = await session.get(LegacyActorIdentity, expected_actor_id) profiles = ( await session.scalars( - select(ActorProfile) - .where(ActorProfile.actor_id == expected_actor_id) - .order_by(ActorProfile.profile_type.asc(), ActorProfile.scope_type.asc()) + select(LegacyWorkflowEligibility) + .where(LegacyWorkflowEligibility.actor_id == expected_actor_id) + .order_by( + LegacyWorkflowEligibility.profile_type.asc(), + LegacyWorkflowEligibility.scope_type.asc(), + ) ) ).all() return identity, list(profiles) @@ -696,7 +737,23 @@ async def seed_worker_profile(subject: str, *, skill_tags: list[str] | None = No async with db_session.get_session_factory()() as session: session.add_all( [ - ActorIdentity( + ActorProfile( + id=worker_actor_id, + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + created_by=worker_actor_id, + ), + ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=worker_actor_id, + issuer="flow-test", + subject=subject, + subject_kind="human", + status="active", + linked_by=worker_actor_id, + ), + LegacyActorIdentity( actor_id=worker_actor_id, external_subject=subject, external_issuer="flow-test", @@ -707,7 +764,7 @@ async def seed_worker_profile(subject: str, *, skill_tags: list[str] | None = No auth_source="dev_mock", is_dev_auth=True, ), - ActorProfile( + LegacyWorkflowEligibility( id=str(uuid4()), actor_id=worker_actor_id, profile_type="worker", @@ -735,7 +792,23 @@ async def seed_actor_profile( async with db_session.get_session_factory()() as session: session.add_all( [ - ActorIdentity( + ActorProfile( + id=seeded_actor_id, + actor_kind="human", + status="active", + provisioning_method="automatic_first_access", + created_by=seeded_actor_id, + ), + ActorIdentityLink( + id=str(uuid4()), + actor_profile_id=seeded_actor_id, + issuer="flow-test", + subject=subject, + subject_kind="human", + status="active", + linked_by=seeded_actor_id, + ), + LegacyActorIdentity( actor_id=seeded_actor_id, external_subject=subject, external_issuer="flow-test", @@ -746,7 +819,7 @@ async def seed_actor_profile( auth_source="dev_mock", is_dev_auth=True, ), - ActorProfile( + LegacyWorkflowEligibility( id=str(uuid4()), actor_id=seeded_actor_id, profile_type=profile_type, @@ -764,8 +837,10 @@ async def seed_actor_profile( def test_task_models_are_registered_for_alembic_metadata() -> None: expected_tables = { - "actor_identities", "actor_profiles", + "actor_identity_links", + "legacy_actor_identities", + "legacy_workflow_eligibility", "workstream_tasks", "task_assignments", "submissions", @@ -776,8 +851,10 @@ def test_task_models_are_registered_for_alembic_metadata() -> None: assert expected_tables.issubset(Base.metadata.tables) assert "worker_profiles" not in Base.metadata.tables assert "reviewer_profiles" not in Base.metadata.tables - assert db_models.ActorIdentity is ActorIdentity assert db_models.ActorProfile is ActorProfile + assert db_models.ActorIdentityLink is ActorIdentityLink + assert db_models.LegacyActorIdentity is LegacyActorIdentity + assert db_models.LegacyWorkflowEligibility is LegacyWorkflowEligibility assert not hasattr(db_models, "WorkerProfile") assert not hasattr(db_models, "ReviewerProfile") assert db_models.WorkstreamTask is WorkstreamTask @@ -794,8 +871,10 @@ async def test_chunk4_migration_creates_expected_tables(task_database_env: str) ) assert { - "actor_identities", "actor_profiles", + "actor_identity_links", + "legacy_actor_identities", + "legacy_workflow_eligibility", "workstream_tasks", "task_assignments", "submissions", @@ -1021,7 +1100,9 @@ async def fail_with_permission_error(*_args, **_kwargs): assert denied.json()["error"]["code"] == "permission_not_granted" -async def test_actor_profile_services_update_existing_actor_rows(task_database_env: str) -> None: +async def test_legacy_eligibility_service_updates_existing_submitter_row( + task_database_env: str, +) -> None: async with db_session.get_session_factory()() as session: service = ActorService(session) worker_actor = ActorContext( @@ -1035,77 +1116,31 @@ async def test_actor_profile_services_update_existing_actor_rows(task_database_e auth_source="dev_mock", is_dev_auth=True, ) - first_worker = await service.activate_worker_profile( + first_worker = await service.activate_legacy_workflow_eligibility( worker_actor, - ActorProfileActivationRequest(skill_tags=["stem"]), + LegacyWorkflowEligibilityActivationRequest(skill_tags=["stem"]), ) - updated_worker = await service.activate_worker_profile( + updated_worker = await service.activate_legacy_workflow_eligibility( worker_actor.model_copy( update={"display_name": "Worker Updated", "email": "worker-updated@example.test"} ), - ActorProfileActivationRequest(skill_tags=["stem", "analysis"]), - ) - - reviewer_actor = ActorContext( - actor_id=actor_id("reviewer-upsert"), - external_subject="reviewer-upsert", - external_issuer="flow-test", - display_name="Reviewer Upsert", - email="reviewer-upsert@example.test", - roles=("reviewer",), - claim_snapshot={"roles": ("reviewer",)}, - auth_source="dev_mock", - is_dev_auth=True, - ) - first_reviewer = await service.ensure_observed_profile( - reviewer_actor, - profile_type="reviewer", - scope_type="global", - scope_id="global", - profile_metadata={"source": "test"}, - ) - updated_reviewer = await service.ensure_observed_profile( - reviewer_actor.model_copy( - update={ - "display_name": "Reviewer Updated", - "email": "reviewer-updated@example.test", - } - ), - profile_type="reviewer", - scope_type="global", - scope_id="global", - profile_metadata={"source": "test_refresh"}, + LegacyWorkflowEligibilityActivationRequest(skill_tags=["stem", "analysis"]), ) - await session.commit() async with db_session.get_session_factory()() as session: worker_rows = ( await session.execute( - select(ActorProfile).where( - ActorProfile.actor_id == worker_actor.actor_id, - ActorProfile.profile_type == "worker", - ) - ) - ).scalars().all() - reviewer_rows = ( - await session.execute( - select(ActorProfile).where( - ActorProfile.actor_id == reviewer_actor.actor_id, - ActorProfile.profile_type == "reviewer", + select(LegacyWorkflowEligibility).where( + LegacyWorkflowEligibility.actor_id == worker_actor.actor_id, + LegacyWorkflowEligibility.profile_type == "worker", ) ) ).scalars().all() assert updated_worker.id == first_worker.id - assert updated_worker.display_name == "Worker Updated" assert updated_worker.skill_tags == ["stem", "analysis"] assert len(worker_rows) == 1 assert worker_rows[0].id == first_worker.id - assert updated_reviewer.id == first_reviewer.id - assert updated_reviewer.status == "observed" - assert updated_reviewer.profile_metadata == {"source": "test_refresh"} - assert len(reviewer_rows) == 1 - assert reviewer_rows[0].id == first_reviewer.id async def test_task_can_be_created_in_draft(task_client: AsyncClient) -> None: @@ -2106,7 +2141,7 @@ async def test_worker_without_profile_cannot_claim_ready_task( ) assert response.status_code == 403 - assert "active worker profile" in response.json()["detail"] + assert "active legacy submitter eligibility" in response.json()["detail"] async def test_disabled_worker_profile_cannot_claim_ready_task( @@ -2125,7 +2160,7 @@ async def test_disabled_worker_profile_cannot_claim_ready_task( ) assert response.status_code == 403 - assert "active worker profile" in response.json()["detail"] + assert "active legacy submitter eligibility" in response.json()["detail"] async with db_session.get_session_factory()() as session: assignment = await session.scalar( select(TaskAssignment).where(TaskAssignment.task_id == ready_task["id"]) @@ -2136,6 +2171,50 @@ async def test_disabled_worker_profile_cannot_claim_ready_task( assert task.status == "ready" +async def test_disabled_legacy_eligibility_after_claim_blocks_assigned_submitter_start( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + ready_task = await create_ready_task(task_client, project["id"]) + worker_actor_id = await seed_worker_profile("eligibility-disabled-after-claim") + set_dev_actor( + monkeypatch, + roles="worker", + subject="eligibility-disabled-after-claim", + ) + claim = await task_client.post( + f"/api/v1/tasks/{ready_task['id']}/claim", + headers=auth_headers(), + json={"reason": "claim while eligible"}, + ) + assert claim.status_code == 200, claim.text + + async with db_session.get_session_factory()() as session: + eligibility = await session.scalar( + select(LegacyWorkflowEligibility).where( + LegacyWorkflowEligibility.actor_id == worker_actor_id, + LegacyWorkflowEligibility.profile_type == "worker", + ) + ) + assert eligibility is not None + eligibility.status = "disabled" + await session.commit() + + start = await task_client.post( + f"/api/v1/tasks/{ready_task['id']}/start", + headers=auth_headers(), + json={"reason": "start after eligibility disabled"}, + ) + assert start.status_code == 403 + assert "active legacy submitter eligibility" in start.json()["detail"] + read = await task_client.get( + f"/api/v1/tasks/{ready_task['id']}", headers=auth_headers() + ) + assert read.status_code == 200 + assert read.json()["status"] == "claimed" + + async def test_active_worker_profile_without_worker_token_cannot_claim( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, @@ -2216,7 +2295,7 @@ async def test_worker_can_create_profile_before_claiming_task( assert claim.json()["assignment"]["worker_id"] == actor_id("worker-self-profile") -async def test_worker_profile_response_includes_nullable_identity_fields( +async def test_worker_profile_response_excludes_identity_display_fields( task_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2239,8 +2318,8 @@ async def test_worker_profile_response_includes_nullable_identity_fields( assert body["actor_id"] == actor_id("worker-null-identity") assert body["external_subject"] == "worker-null-identity" assert body["external_issuer"] == "flow-test" - assert body["display_name"] is None - assert body["email"] is None + assert "display_name" not in body + assert "email" not in body assert body["skill_tags"] == [] assert body["status"] == "active" @@ -2272,8 +2351,8 @@ async def test_worker_profile_request_is_fail_closed_and_validated( assert unknown_field.status_code == 422 assert field_name in unknown_field.text - identity, profiles = await fetch_actor_registry_rows(subject) - malicious_identity, malicious_profiles = await fetch_actor_registry_rows("malicious") + identity, profiles = await fetch_legacy_actor_rows(subject) + malicious_identity, malicious_profiles = await fetch_legacy_actor_rows("malicious") assert malicious_identity is None assert malicious_profiles == [] @@ -2284,9 +2363,7 @@ async def test_worker_profile_request_is_fail_closed_and_validated( assert identity.email is None assert identity.display_name is None assert identity.last_seen_roles == ["worker"] - assert [(profile.profile_type, profile.status, profile.scope_type, profile.scope_id) for profile in profiles] == [ - ("worker", "observed", "global", "global") - ] + assert profiles == [] blank_tag = await task_client.post( "/api/v1/workers/me/profile", @@ -2300,9 +2377,9 @@ async def test_worker_profile_request_is_fail_closed_and_validated( ) assert blank_tag.status_code == 422 - assert "skill_tags cannot include empty values" in blank_tag.text + assert "invalid skill tag" in blank_tag.text assert long_tag.status_code == 422 - assert "skill_tags values must be 64 characters or fewer" in long_tag.text + assert "invalid skill tag" in long_tag.text async def test_registered_claim_route_rejects_identity_spoof_fields( @@ -2336,8 +2413,8 @@ async def test_registered_claim_route_rejects_identity_spoof_fields( assert response.status_code == 422 assert field_name in response.text - identity, profiles = await fetch_actor_registry_rows("worker-claim-overpost") - malicious_identity, malicious_profiles = await fetch_actor_registry_rows("malicious") + identity, profiles = await fetch_legacy_actor_rows("worker-claim-overpost") + malicious_identity, malicious_profiles = await fetch_legacy_actor_rows("malicious") assert malicious_identity is None assert malicious_profiles == [] diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 9a131be1..46f20775 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -4,8 +4,9 @@ This runbook assigns ownership and stop conditions for the staged WS-AUTH-001 authorization rollout. The verified-token configuration and evidence commands -are executable contracts; later actor/grant sections remain staged until their -owning implementation chunks. +are executable contracts. Canonical actor resolution is active in AUTH-06; +later actor lifecycle administration and grant sections remain staged until +their owning implementation chunks. ## Ownership @@ -29,7 +30,7 @@ preview fail closed when a required value is absent or outside its bound. | Variable | Accepted value | Default/requirement | |---|---|---| -| `WORKSTREAM_TOKEN_ISSUER` | Canonical HTTPS URL; no userinfo, query, or fragment | Required | +| `WORKSTREAM_TOKEN_ISSUER` | Canonical HTTPS URL, at most 200 characters; no userinfo, query, or fragment | Required | | `WORKSTREAM_TOKEN_AUDIENCE` | Non-empty string | `workstream` | | `WORKSTREAM_TOKEN_JWKS_URL` | Canonical HTTPS URL; no userinfo, query, or fragment | Required | | `WORKSTREAM_TOKEN_ALGORITHMS` | One-family subset of `RS256,RS384,RS512,ES256,ES384,ES512,EdDSA` | Required; no symmetric algorithms | @@ -60,7 +61,7 @@ preview fail closed when a required value is absent or outside its bound. | `WORKSTREAM_TOKEN_INTROSPECTION_WRITE_TIMEOUT_SECONDS` | Float `0.1..10` | `3` | | `WORKSTREAM_TOKEN_INTROSPECTION_POOL_TIMEOUT_SECONDS` | Float `0.1..10` | `1` | | `WORKSTREAM_TOKEN_INTROSPECTION_TOTAL_TIMEOUT_SECONDS` | Float `0.5..15` | `5` | -| `WORKSTREAM_API_RATE_LIMIT_KEY_SECRET` | Canonical padded RFC 4648 Base64 decoding to `32..64` bytes | Optional until a protected route is attached; then required | +| `WORKSTREAM_API_RATE_LIMIT_KEY_SECRET` | Canonical padded RFC 4648 Base64 decoding to `32..64` bytes | Required | | `WORKSTREAM_API_FIRST_ACCESS_RATE_LIMIT` | Integer `1..10000` | `10` | | `WORKSTREAM_API_FIRST_ACCESS_RATE_WINDOW_SECONDS` | Integer `1..3600` | `60` | | `WORKSTREAM_API_ADMIN_MUTATION_RATE_LIMIT` | Integer `1..10000` | `30` | @@ -69,6 +70,11 @@ preview fail closed when a required value is absent or outside its bound. Secrets, private keys, bearer tokens, full claims, and raw JWKS documents must not appear in committed configuration or evidence. +Verified issuer and `sub` identity anchors are each limited to 200 characters +across the verifier, canonical registry, compatibility storage, audit, and +checker provenance. Development issuer and subject settings use the same bound. +Oversized values fail verification or configuration before actor persistence. + Verification evidence: ```bash @@ -96,12 +102,12 @@ python3 scripts/check_loop_memory_state.py git diff --check ``` -During the compatibility period, `/api/v1/auth/me` and actor registration use -only the verified issuer/subject plus bounded legacy roles. They do not copy -issuer email or display name into actor storage or responses, so both response -fields remain `null`. Consumers must not treat token identity metadata as a -profile source of truth; canonical profile metadata belongs to the later actor -profile migration. +During the compatibility period, `/api/v1/auth/me` uses only the verified +issuer/subject plus bounded legacy roles. It does not copy issuer email or +display name into actor storage or responses, so both response fields remain +`null`. Consumers must not treat token identity metadata or legacy workflow +eligibility as profile or authorization truth. Human-owned display data is +written only through `PATCH /api/v1/actors/me`. ## Request And Error Context @@ -149,14 +155,46 @@ claims, subjects, emails, SQL, provider bodies, exception text, or secrets to responses or logs. AUTH-04A does not activate rate controls, grant APIs, or new product authority; those remain owned by later separately reviewed chunks. +## Canonical Actor Resolution + +Every protected human request resolves the exact verified `(issuer, subject)` +through one `ActorIdentityLink` to one local `ActorProfile`. The first valid +human access consumes the PostgreSQL first-access rate control, then creates the +profile, identity link, `ActorProfileProvisioned`, and `ActorIdentityLinked` +evidence in one transaction. Concurrent allowed first-access requests cannot +leave duplicate profiles, links, or evidence. Rate-limited requests may still +receive HTTP 429 before identity serialization. + +`GET /api/v1/actors/me` returns the caller's privacy-bounded Contributor-domain +profile and no admin role or project grant. `PATCH /api/v1/actors/me` accepts +only `display_name` and `contact_email`; token roles, issuer metadata, actor +kind, status, grants, and lifecycle fields are not writable there. Suspended +profiles remain readable for support, but suspended or deactivated profiles +cannot be mutated or use legacy product routes. + +Unknown services require later manual provisioning and are denied without a +write. Agent and Space subjects are denied without a write. Operators must not +convert token roles, email shape, subject shape, or old typed profiles into +actor kind or authority. + +The [approved AUTH-06 chunk contract](../.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-06-canonical-actor-profile.md) +records the exact deprecated compatibility identifier. That temporary, +enumerated intake route writes only `LegacyWorkflowEligibility` and cannot +create a grant or change a canonical profile. Its direct compatibility +consumers are assigned-submitter claim, assigned-submitter start, and submission +intake. Operator start override does not use the bridge. AUTH-13 removes the +claim and start consumers; AUTH-14 removes the final submission consumer, +compatibility route, and adapter. + ## PostgreSQL Rate Controls -AUTH-04B provides unattached dependencies for future first-access and -authority-management mutations. No production route is rate limited by this -chunk. When an owning route attaches one, every replica must use the same -secret and settings. Missing secret or database access fails the dependency -closed with retryable HTTP 503; an exhausted window returns retryable HTTP 429 -with an integer `Retry-After`. +First human access now uses the AUTH-04B PostgreSQL control. Future +authority-management mutations attach their separately configured control in +their owning chunks. Every replica must use the same secret and settings. +Missing secret or database access fails first access closed with retryable HTTP +503; an exhausted window returns retryable HTTP 429 with an integer +`Retry-After`. Existing exact identity links do not consume first-access +capacity. Generate the secret outside the repository and store it in the deployment secret manager: @@ -337,27 +375,88 @@ globally unique deployment identity. A clone, restore, or database recreation requires a fresh dry run and envelope even when the human environment label is unchanged. -The later actor-schema migration must locate this envelope only through: +The canonical actor-schema migration locates this envelope only through: ```bash export WORKSTREAM_LEGACY_ACTOR_CLASSIFICATION_FILE=/secure/workstream/prod/legacy-actor-classification-v1.json ``` -Do not set the variable or run the future migration until its owning reviewed -AUTH chunk is deployed. That migration must load the strict envelope, recompute -the complete live row-set digest and database binding inside its transaction, -and abort on checksum, TOCTOU, missing/extra row, identity, or binding mismatch. -The envelope never creates grants and is not a supported ad hoc migration path. +Set the variable only on the reviewed AUTH-06 migration runner. The migration +loads the strict envelope, recomputes the complete live row-set digest and +database binding inside its transaction, and aborts on checksum, TOCTOU, +missing/extra row, identity, or binding mismatch. The envelope never creates +grants and is not a supported ad hoc migration path. On validation failure, correct the authoritative manifest or target database, rerun dry-run, and export to a new secure path when the evidence bytes change. -Never edit an envelope or bypass the failure with SQL. Rollback stops at the -pre-migration schema boundary; it does not restore obsolete authority. Retain -the envelope only through migration verification and the approved rollback -window. After the migration records the schema version, manifest checksum, -envelope checksum, source-row checksum, and migration result durably, delete -the identity-bearing manifest and envelope from operator storage and retain -only the bounded report and durable checksum record. +Never edit an envelope or bypass the failure with SQL. + +Deploy AUTH-06 only in a quiesced maintenance window after the dry-run report +and envelope have been reviewed: + +1. Drain in-flight API requests and jobs. +2. Stop every old-version API replica and asynchronous writer. +3. Run the migration from the reviewed AUTH-06 release artifact. +4. Start only replicas and jobs containing the matching AUTH-06 code. +5. Complete the checks below before resuming traffic. + +```bash +cd backend +export WORKSTREAM_LEGACY_ACTOR_CLASSIFICATION_FILE=/secure/workstream/prod/legacy-actor-classification-v1.json +.venv/bin/alembic upgrade 0020_canonical_actor_profile +``` + +Verify one durable migration-state row before serving traffic. Record only the +schema version, classified count, and checksums; do not export identity rows: + +```sql +select schema_version, classified_count, source_row_set_sha256, + manifest_sha256, envelope_sha256, migrated_at +from actor_profile_migration_state where id = 1; + +select + (select count(*) from actor_profiles) as profile_count, + (select count(*) from actor_identity_links) as identity_link_count, + not exists ( + select 1 from actor_profiles p + full join actor_identity_links l on l.actor_profile_id = p.id + where p.id is null or l.id is null + ) as exact_one_link_per_profile, + to_regclass('public.admin_role_grants') is null as no_admin_grant_table, + to_regclass('public.project_role_grants') is null as no_project_grant_table; +``` + +Confirm every canonical profile has exactly one identity link, the classified +count matches the reviewed report, and no grants were created by the migration. +Retain the manifest and envelope only through verification and the approved +rollback window. Then delete both identity-bearing files from operator storage +and retain only the bounded report and durable checksum record. + +Rollback to `0019_authority_idempotency` uses the same quiescence sequence: drain +requests and jobs, stop all AUTH-06 writers, run the downgrade, deploy only the +matching pre-AUTH-06 code, verify, and then resume traffic. The downgrade is +envelope-independent because its required state is in PostgreSQL. It refuses +while any profile is suspended or any identity link is revoked; those states +may be repaired only through their reviewed lifecycle operations, never direct +SQL. Deactivation is terminal: if any actor is deactivated, downgrade is +unavailable and operators must recover forward on AUTH-06. A non-empty registry +restored to 0019 cannot be upgraded again from deleted evidence: run the +classification tool against the exact restored rows and obtain a newly reviewed +envelope. The migration never guesses a subject kind or bypasses this +fresh-evidence requirement. + +Run the exact rollback only after those checks pass: + +```bash +cd backend +.venv/bin/alembic downgrade 0019_authority_idempotency +``` + +Rollback restores every new canonical identity to legacy identity storage. +For identities that already existed before AUTH-06, conflict handling preserves +their legacy display name and email; it does not backport later canonical +`PATCH /api/v1/actors/me` display edits into those legacy fields. This is an +intentional privacy boundary, not a synchronization mechanism. ## Staged Rollout From 63f1dd3c2705d515c819da663b9413318dd12176 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 15 Jul 2026 06:21:27 +0100 Subject: [PATCH 2/7] Record AUTH-06 review evidence --- ...WS-AUTH-001-06-internal-review-evidence.md | 65 ++++++++ .../reviews/WS-AUTH-001-06-pr-trust-bundle.md | 152 ++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md new file mode 100644 index 00000000..909102e9 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md @@ -0,0 +1,65 @@ +# WS-AUTH-001-06 Internal Review Evidence + +Reviewed code SHA: `13cd665ffc683d59e83bca70f370080067ef3fe9` +Reviewed runtime SHA: `13cd665ffc683d59e83bca70f370080067ef3fe9` +Reviewed at: `2026-07-15T05:20:21Z` +Reviewer run IDs: `auth06_final_senior`, `auth06_qa_review`, +`auth06_security_review`, `auth06_product_ops_review`, +`auth06_final_architecture`, `auth06_final_ci`, `auth06_final_docs`, +`auth06_final_reuse`, `auth06_final_test_delta` + +## Deterministic Evidence + +- Actor subsystem branch coverage reached 90.0929 percent across 803 + statements and 166 branches, above the required 90 percent gate. +- The broad actor, authentication, task, project, checker, rate-control, and + migration coverage run completed 186 behavior tests before one static + allowlist assertion exposed the new shared rate-control module. The allowlist + was repaired, its focused proof passed, and three additional behavior tests + strengthened repeated access, unavailable rate control, and idempotent legacy + activation before the final coverage report. +- High-risk migration rerun: 3 passed in 57.32 seconds, covering terminal-state + downgrade refusal, privacy-safe invalid-row handling, and classified identity + and attribution preservation. +- Real Postgres API contract passed through migration `0020`, actor + provisioning, compatibility projection, task claim/start/submission, and + authorization denials. +- Ruff passed for backend application, tests, migration, and API contract code. +- Stale Workstream wording, stale authorization documentation, changed Markdown + links, and `git diff --check` passed. +- All 63 engineering-loop agent-gate tests passed. +- No workflow, dependency, coverage threshold, skip, exclusion, or package + script changed. GitHub Backend remains authoritative for the repository-wide + 78 percent floor; the multi-hour full suite was not repeated locally. + +## Reviewer Results + +| Reviewer | Result | Blocking findings | Notes | +|---|---|---|---| +| senior engineering | PASS | none | Transaction ownership, identity resolution, compatibility boundaries, and failure mapping pass. | +| qa/test | PASS | none | Provisioning, idempotency, migration, rollback, rate-control, and legacy behavior are covered. | +| security/auth | PASS | none | Issuer-subject identity, non-human denial, bounded claims, privacy, and zero-write failures pass. | +| product/ops | PASS | none | Contributor semantics and bounded legacy submitter compatibility match the approved lifecycle. | +| architecture | PASS | none | Canonical actor ownership and shared dependency boundaries pass; accepted low operational risks are documented. | +| ci integrity | PASS | none | CI policy, thresholds, dependencies, and exclusions are unchanged. | +| docs | PASS | none | Deployment, rollback, privacy, bounds, and compatibility documentation match runtime behavior. | +| reuse/dedup | PASS | none | Shared rate-control and actor failure mapping replace duplicate route behavior. | +| test delta | PASS | none | New assertions exercise meaningful behavior without skips, xfails, or weakened expectations. | + +## Findings Resolved + +Review repair closed stale eligibility on assigned task start, operator-override +regression, deactivated-profile access, non-human zero-write proof, rate-control +unavailability, mid-audit rollback, repeated-access timestamp behavior, +idempotent compatibility activation, migration index and downgrade gaps, +invalid-row privacy, claim-bound inconsistency, duplicate error mapping, and +active-document legacy terminology. + +Valid findings addressed: yes + +Open sub-agent sessions: none + +## Remaining Gate + +GitHub Backend, Agent Gates, CodeRabbit, and explicit human merge approval +remain pending. `WS-AUTH-001-07` must not start automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md new file mode 100644 index 00000000..35ebba73 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md @@ -0,0 +1,152 @@ +# WS-AUTH-001-06 PR Trust Bundle + +## Chunk + +`WS-AUTH-001-06` - Canonical Actor Profile And Identity Resolution + +## Goal + +Replace the legacy human registry as an authorization authority with one +canonical actor profile and issuer-subject identity link, while preserving a +strictly bounded compatibility bridge for existing submitter workflows. + +## Human-Approved Intent + +Every verified human receives one canonical profile. Authorization is based on +explicit authority and project grants, not a default contributor role. Human +product language uses contributor, submitter, and reviewer; worker remains only +for internal service execution or explicitly inventoried legacy identifiers. + +## What Changed + +- Migration `0020` classifies legacy identities, creates canonical actor + profiles and identity links, enforces identity and lifecycle invariants, and + provides guarded downgrade custody. +- Exact issuer-subject resolution serializes first access, atomically creates + one profile/link plus authority evidence, and updates verification timestamps + using database time. +- `GET/PATCH /api/v1/actors/me` exposes the canonical self-service contract; + `/api/v1/auth/me` remains a compatibility view. +- Verified agent, service, and Space identities fail closed without actor state. +- First-access rate control now uses a shared dependency and maps unavailable + infrastructure to a retryable, zero-write response. +- Legacy submitter compatibility is restricted to the existing profile, + claim, start, and submission boundary and cannot become authorization truth. + +## Why It Changed + +Later grant and permission chunks require a stable actor reference that is not +coupled to email, mutable token display claims, or a legacy submitter record. +This chunk establishes that identity boundary before authorization policy is +activated. + +## Design Chosen + +External identity is keyed only by normalized issuer and subject. Human profile +state is separate from identity links and defaults to active without assigning +a role. First creation is serialized with a PostgreSQL advisory transaction +lock. Runtime repositories do not own commits or sessions, and authority events +are written in the same transaction as actor state. + +## Alternatives Rejected + +- Email identity and Workstream-owned login were rejected because the external + identity issuer owns authentication. +- A default contributor role was rejected because capabilities must come from + explicit admin and project grants. +- Keeping the legacy registry authoritative was rejected because contributor, + reviewer, and manager access must resolve through one canonical actor. +- Provisioning non-human principals through this route was rejected; later + service-principal custody must be explicit. + +## Scope Control + +This chunk does not implement admin-role grants, project-role grants, the +permission evaluator, artifact-storage authorization, cache consumers, frontend +identity flows, or service-principal provisioning. Those remain in later AUTH +chunks. + +## Product Behavior + +A verified human is provisioned on first access and receives a stable actor ID. +They can read and update bounded display fields. Suspended identities remain +readable but cannot mutate; deactivated identities fail closed. Existing task +submitter flows continue through a compatibility projection while current +authorization remains unchanged until the grant and evaluator cutovers. + +## Acceptance Criteria Proof + +- Concurrent and repeated access produce one actor profile and one identity + link with advancing verification timestamps and no duplicate authority rows. +- Unknown services and non-human identities are denied before persistence. +- Issuer and subject claims are consistently bounded at 200 characters across + token parsing, verifier adapters, actor persistence, migration, and audit + provenance. +- Migration upgrade, classification, constraints, indexes, privacy, guarded + downgrade, re-upgrade, and terminal deactivation behavior have direct tests. +- Rate-control and audit failures roll back actor state and evidence together. +- Legacy activation is idempotent and audits only actual changes. + +## Tests And Checks Run + +- Actor subsystem branch coverage: 90.0929 percent. +- High-risk migration rerun: 3 passed in 57.32 seconds. +- Real Postgres API contract: passed end to end through migration `0020`. +- Focused actor/auth/task/project/checker/rate-control behavior and migration + suites passed; final coverage data includes the repaired static inventory and + added repeated-access, unavailable-control, and compatibility assertions. +- Ruff, stale wording, stale authorization docs, changed Markdown links, diff + integrity, and all 63 engineering-loop agent-gate tests passed. +- The multi-hour repository suite was not repeated locally. GitHub Backend owns + the authoritative full-suite result and repository-wide 78 percent floor. + +## Test Delta + +Tests replace obsolete registry-authority assumptions with canonical actor +behavior and add concurrency, rollback, privacy, lifecycle, migration, and +compatibility proof. No skip, xfail, coverage threshold, or meaningful +assertion was removed or weakened. + +## CI Integrity + +No workflow, dependency, package script, coverage exclusion, or threshold +changed. The actor subsystem meets its 90 percent branch gate, and GitHub must +still enforce the repository-wide 78 percent baseline. + +## Reviewer Results + +Exact reviewed SHA `13cd665ffc683d59e83bca70f370080067ef3fe9` passed senior +engineering, QA/test, security/auth, product/ops, architecture, CI integrity, +docs, reuse/dedup, and test-delta review with no blocking findings. + +## External Review + +Pending GitHub checks, CodeRabbit, and human review. + +## Remaining Risks + +- Database-owner or DDL credentials can bypass normal-DML guards; production + must use the documented non-owner runtime role. +- Migration `0020` requires a quiesced deployment because it classifies and + swaps legacy identity storage. +- Deactivation is terminal for downgrade purposes and requires forward recovery. +- The compatibility projection remains until its owning cutover chunk removes + the legacy route and storage. + +## Follow-Up Work + +After merge, automated post-merge memory, stop, and a separate human start, +AUTH-07 may implement the next approved authority boundary. Later chunks own +admin grants, project grants, the evaluator, API cutovers, artifact-storage +authorization, and invalidation consumers. + +## Human Review Focus + +Review migration classification and downgrade custody, first-access transaction +and rate-control behavior, issuer-subject bounds, non-human zero-write denial, +and the exact limits of the legacy compatibility bridge. + +## Human Merge Ownership + +Only the human may approve and merge this PR. Internal or external checks do +not authorize merge. From 60af5184c960c87c9eb3c85945107e416d83116a Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 15 Jul 2026 07:11:59 +0100 Subject: [PATCH 3/7] Address AUTH-06 review findings --- .../WS-AUTH-001-06-canonical-actor-profile.md | 1 + .../versions/0020_canonical_actor_profile.py | 3 +- backend/app/adapters/auth/dev.py | 18 +- backend/app/adapters/auth/flow.py | 3 +- backend/app/modules/actors/service.py | 4 + backend/app/modules/tasks/schemas.py | 10 +- backend/app/modules/tasks/service.py | 65 ++++-- backend/scripts/api_contract_e2e.py | 3 + backend/tests/conftest.py | 56 ++++- backend/tests/test_actors.py | 142 ++++++++----- backend/tests/test_alembic.py | 139 ++++++++++--- backend/tests/test_auth.py | 193 ++++++++++++++---- backend/tests/test_checkers.py | 53 ++--- backend/tests/test_projects.py | 53 ++--- backend/tests/test_tasks.py | 145 ++++++++++--- docs/operations_authorization_service.md | 11 +- 16 files changed, 645 insertions(+), 254 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-06-canonical-actor-profile.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-06-canonical-actor-profile.md index 345fced7..df92ca91 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-06-canonical-actor-profile.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/chunks/WS-AUTH-001-06-canonical-actor-profile.md @@ -54,6 +54,7 @@ backend/tests/test_alembic.py backend/tests/test_projects.py backend/tests/test_tasks.py backend/tests/test_checkers.py +backend/tests/conftest.py backend/scripts/api_contract_e2e.py docs/operations_authorization_service.md .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/** diff --git a/backend/alembic/versions/0020_canonical_actor_profile.py b/backend/alembic/versions/0020_canonical_actor_profile.py index 5312500c..c965c306 100644 --- a/backend/alembic/versions/0020_canonical_actor_profile.py +++ b/backend/alembic/versions/0020_canonical_actor_profile.py @@ -294,7 +294,7 @@ def upgrade() -> None: kind = kinds[row.actor_id] profile_method = "automatic_first_access" if kind == "human" else "manual_service_provisioning" creator = row.actor_id if kind == "human" else "workstream:system:legacy-migration" - link_id = str(uuid5(NAMESPACE_URL, f"workstream:identity-link:{row.external_issuer}:{row.external_subject}")) + link_id = str(uuid5(NAMESPACE_URL, f"workstream:identity-link:{row.actor_id}")) bind.execute( sa.text( "insert into actor_profiles " @@ -355,6 +355,7 @@ def downgrade() -> None: "select p.id,l.subject,l.issuer,p.display_name,p.contact_email,'[]'::json,'{}'::json,'flow',false,p.created_at,coalesce(p.last_seen_at,p.created_at),p.updated_at " "from actor_profiles p join actor_identity_links l on l.actor_profile_id=p.id " "on conflict (actor_id) do update set " + "display_name=excluded.display_name,email=excluded.email," "last_seen_at=excluded.last_seen_at,updated_at=excluded.updated_at" ) ) diff --git a/backend/app/adapters/auth/dev.py b/backend/app/adapters/auth/dev.py index 31100fa4..e5a8e646 100644 --- a/backend/app/adapters/auth/dev.py +++ b/backend/app/adapters/auth/dev.py @@ -35,17 +35,23 @@ def __init__(self, settings: Settings) -> None: raise RuntimeError("development auth cannot run in production") if not settings.dev_auth_token: raise RuntimeError("WORKSTREAM_DEV_AUTH_TOKEN must be set for development auth") + subject = settings.dev_auth_subject or "" if ( - not settings.dev_auth_subject - or len(settings.dev_auth_subject) > MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS + not subject.strip() + or subject != subject.strip() + or len(subject) > MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS ): raise RuntimeError("WORKSTREAM_DEV_AUTH_SUBJECT must be set for development auth") + issuer = settings.dev_auth_issuer or "" if ( - not settings.dev_auth_issuer - or len(settings.dev_auth_issuer) > MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS + not issuer.strip() + or issuer != issuer.strip() + or len(issuer) > MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS ): raise RuntimeError("WORKSTREAM_DEV_AUTH_ISSUER must be set for development auth") self._settings = settings + self._subject = subject + self._issuer = issuer async def verify(self, token: str) -> AuthVerificationResult: """Verify a local bearer token and return canonical and legacy views. @@ -67,8 +73,8 @@ async def verify(self, token: str) -> AuthVerificationResult: now = int(datetime.now(UTC).timestamp()) return AuthVerificationResult( token=VerifiedIssuerToken( - issuer=self._settings.dev_auth_issuer, - subject=self._settings.dev_auth_subject, + issuer=self._issuer, + subject=self._subject, audience=("workstream",), expires_at=now + int(timedelta(days=1).total_seconds()), issued_at=now, diff --git a/backend/app/adapters/auth/flow.py b/backend/app/adapters/auth/flow.py index 91b552d6..9ee5d9b9 100644 --- a/backend/app/adapters/auth/flow.py +++ b/backend/app/adapters/auth/flow.py @@ -401,7 +401,8 @@ def _result_from_verified_claims( subject_kind = claims.get("subject_kind") if ( not isinstance(subject, str) - or not subject + or not subject.strip() + or subject != subject.strip() or len(subject) > MAX_VERIFIED_IDENTITY_ANCHOR_CHARACTERS ): raise AuthVerificationError("token subject is required") diff --git a/backend/app/modules/actors/service.py b/backend/app/modules/actors/service.py index cd89b144..8217e316 100644 --- a/backend/app/modules/actors/service.py +++ b/backend/app/modules/actors/service.py @@ -188,6 +188,10 @@ async def activate_legacy_workflow_eligibility( ) -> LegacyWorkflowEligibilityResponse: """Activate temporary submitter intake metadata without creating authority.""" require_any_role(actor, {"worker"}) + await self._repo.lock_external_identity( + actor.external_issuer, + actor.external_subject, + ) identity = await self._repo.upsert_legacy_identity(self._legacy_identity_from_actor(actor)) eligibility = await self._repo.get_legacy_eligibility( actor.actor_id, diff --git a/backend/app/modules/tasks/schemas.py b/backend/app/modules/tasks/schemas.py index 8da58ff8..6fd558ec 100644 --- a/backend/app/modules/tasks/schemas.py +++ b/backend/app/modules/tasks/schemas.py @@ -250,7 +250,7 @@ class TaskPaymentPolicyContext(BaseModel): class TaskWorkerLifecycleContext(BaseModel): - """Worker-facing lifecycle state for a task.""" + """Contributor-facing lifecycle state for a task.""" status: str assigned_to_current_actor: bool @@ -272,7 +272,7 @@ class TaskWorkContextResponse(BaseModel): class RequiredArtifactRequirement(BaseModel): - """Worker-facing required artifact rule from the locked effective policy.""" + """Contributor-facing required artifact rule from the locked effective policy.""" key: str path: str @@ -282,7 +282,7 @@ class RequiredArtifactRequirement(BaseModel): class RequiredEvidenceRequirement(BaseModel): - """Worker-facing required evidence rule from the locked effective policy.""" + """Contributor-facing required evidence rule from the locked effective policy.""" key: str label: str @@ -292,7 +292,7 @@ class RequiredEvidenceRequirement(BaseModel): class ForbiddenArtifactRequirement(BaseModel): - """Worker-facing forbidden artifact rule from the locked effective policy.""" + """Contributor-facing forbidden artifact rule from the locked effective policy.""" pattern: str reason: str | None = None @@ -301,7 +301,7 @@ class ForbiddenArtifactRequirement(BaseModel): class StorageReferenceRules(BaseModel): - """Worker-facing storage-reference constraints for staged artifacts.""" + """Contributor-facing storage-reference constraints for staged artifacts.""" allowed_storage_schemes: list[str] allowed_uri_prefixes: list[str] diff --git a/backend/app/modules/tasks/service.py b/backend/app/modules/tasks/service.py index eee82491..65b69b63 100644 --- a/backend/app/modules/tasks/service.py +++ b/backend/app/modules/tasks/service.py @@ -354,14 +354,14 @@ async def get_task_work_context( actor: ActorContext, task_id: str, ) -> TaskWorkContextResponse: - """Return worker-safe work context from the task's locked provenance. + """Return Contributor-safe work context from the task's locked provenance. Args: actor: Verified Flow actor context for the current request. task_id: Task whose context should be returned. Returns: - Worker-facing task, guide, policy, and lifecycle context. + Contributor-facing task, guide, policy, and lifecycle context. Raises: PermissionDenied: If the actor cannot view tasks. @@ -372,7 +372,19 @@ async def get_task_work_context( task = await self._get_task(task_id) await self._ensure_task_visible(actor, task) context = await self._load_locked_task_context(task) - return self._work_context_response(actor, task, context) + eligibility = ( + await self._legacy_workflow_eligibility.get_active_submitter_eligibility( + actor.actor_id + ) + ) + return self._work_context_response( + actor, + task, + context, + has_active_submitter_eligibility=( + "worker" in actor.roles and eligibility is not None + ), + ) async def get_task_submission_requirements( self, @@ -386,7 +398,7 @@ async def get_task_submission_requirements( task_id: Task whose locked requirements should be returned. Returns: - Worker-facing submission artifact requirements. + Contributor-facing submission artifact requirements. Raises: PermissionDenied: If the actor cannot view tasks. @@ -628,13 +640,13 @@ async def create_submission( Args: actor: Verified Flow actor context for the current request. task_id: Task receiving the submission packet. - payload: Submission packet fields supplied by the worker. + payload: Submission packet fields supplied by the Contributor. Returns: Created submission response with evidence items. Raises: - PermissionDenied: If the actor cannot create worker submissions. + PermissionDenied: If the actor cannot create Contributor submissions. TaskProjectNotReady: If locked project policy context is invalid. TaskTransitionBlocked: If task state or assignment does not allow submission. TaskValidationError: If required submission fields are missing. @@ -1501,8 +1513,10 @@ def _work_context_response( actor: ActorContext, task: WorkstreamTask, context: LockedTaskContext, + *, + has_active_submitter_eligibility: bool, ) -> TaskWorkContextResponse: - """Build the worker-safe work-context response.""" + """Build the Contributor-safe work-context response.""" return TaskWorkContextResponse( task=self._worker_safe_task_response(task), project=TaskProjectContext( @@ -1530,7 +1544,11 @@ def _work_context_response( currency=task.currency, payout_type=task.payout_type, ), - lifecycle=self._worker_lifecycle_context(actor, task), + lifecycle=self._worker_lifecycle_context( + actor, + task, + has_active_submitter_eligibility=has_active_submitter_eligibility, + ), ) def _submission_requirements_response( @@ -1538,7 +1556,7 @@ def _submission_requirements_response( task: WorkstreamTask, context: LockedTaskContext, ) -> SubmissionRequirementsResponse: - """Build worker-facing requirements from the locked effective policy.""" + """Build Contributor-facing requirements from the locked effective policy.""" policy = context.effective_policy.effective_policy if not isinstance(policy, dict): raise TaskLockedContextInvalid( @@ -1865,17 +1883,32 @@ def _worker_lifecycle_context( self, actor: ActorContext, task: WorkstreamTask, + *, + has_active_submitter_eligibility: bool, ) -> TaskWorkerLifecycleContext: - """Build worker-facing lifecycle booleans and next actions.""" + """Build Contributor-facing lifecycle booleans and next actions.""" assigned_to_current_actor = task.assigned_to == actor.actor_id - can_submit = assigned_to_current_actor and task.status in { - TASK_STATUS_IN_PROGRESS, - TASK_STATUS_NEEDS_REVISION, - } + can_submit = ( + has_active_submitter_eligibility + and assigned_to_current_actor + and task.status + in { + TASK_STATUS_IN_PROGRESS, + TASK_STATUS_NEEDS_REVISION, + } + ) next_actions: list[str] = [] - if task.status == TASK_STATUS_READY and task.assigned_to is None: + if ( + has_active_submitter_eligibility + and task.status == TASK_STATUS_READY + and task.assigned_to is None + ): next_actions.append("claim") - elif task.status == TASK_STATUS_CLAIMED and assigned_to_current_actor: + elif ( + has_active_submitter_eligibility + and task.status == TASK_STATUS_CLAIMED + and assigned_to_current_actor + ): next_actions.append("start") elif can_submit: next_actions.extend(["run_pre_submit_check", "submit"]) diff --git a/backend/scripts/api_contract_e2e.py b/backend/scripts/api_contract_e2e.py index 1bc3a852..4efbaacb 100644 --- a/backend/scripts/api_contract_e2e.py +++ b/backend/scripts/api_contract_e2e.py @@ -1084,6 +1084,9 @@ async def exercise_api_contract(base_url: str, env: dict[str, str]) -> None: assert canonical_actor["domains"] == ["contributor"] assert canonical_actor["admin_roles"] == [] assert canonical_actor["project_role_grants"] == [] + assert "issuer" not in canonical_actor + assert "subject" not in canonical_actor + assert "roles" not in canonical_actor worker_profile = await request_json( client, "POST", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9d5fb949..87f4339f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,17 +1,65 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Awaitable, Callable, Iterator from contextlib import contextmanager import fcntl import hashlib import os from pathlib import Path +import asyncpg import pytest from app.core.config import get_settings DDL_LOCK_DIRECTORY = Path("/tmp") +TestDatabaseReset = Callable[..., Awaitable[None]] + + +async def _reset_test_database_state( + database_url: str, + *, + include_canonical_actors: bool = False, +) -> None: + """Reset test-owned append-only state under explicit privileged custody.""" + connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) + try: + async with connection.transaction(): + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_update_delete" + ) + await connection.execute( + "alter table audit_events disable trigger audit_events_reject_truncate" + ) + if include_canonical_actors: + await connection.execute( + "alter table actor_profiles disable trigger actor_profile_history_guard" + ) + await connection.execute( + "alter table actor_identity_links disable trigger " + "actor_identity_link_history_guard" + ) + await connection.execute("truncate table audit_events cascade") + await connection.execute("truncate table api_rate_control_counters") + if include_canonical_actors: + await connection.execute( + "truncate table actor_identity_links, actor_profiles cascade" + ) + await connection.execute( + "alter table actor_identity_links enable trigger " + "actor_identity_link_history_guard" + ) + await connection.execute( + "alter table actor_profiles enable trigger actor_profile_history_guard" + ) + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_truncate" + ) + await connection.execute( + "alter table audit_events enable trigger audit_events_reject_update_delete" + ) + finally: + await connection.close() @contextmanager @@ -41,6 +89,12 @@ def lock() -> Iterator[None]: return lock +@pytest.fixture +def reset_test_database_state() -> TestDatabaseReset: + """Return the shared privileged reset used by isolated database suites.""" + return _reset_test_database_state + + @pytest.fixture def postgres_database_url() -> str: value = os.environ.get("WORKSTREAM_TEST_DATABASE_URL") diff --git a/backend/tests/test_actors.py b/backend/tests/test_actors.py index 97c666e8..0d65fd2a 100644 --- a/backend/tests/test_actors.py +++ b/backend/tests/test_actors.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import asyncpg from collections.abc import AsyncIterator, Iterator from datetime import UTC, datetime from pathlib import Path @@ -26,6 +25,7 @@ LegacyActorIdentity, LegacyWorkflowEligibility, ) +from app.modules.actors.repository import ActorRepository from app.modules.actors.schemas import ( ActorProfileUpdateRequest, LegacyWorkflowEligibilityActivationRequest, @@ -53,47 +53,12 @@ RATE_SECRET = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" -async def clear_test_audit_events(database_url: str) -> None: - """Reset append-only evidence under explicit isolated-test ownership.""" - connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) - try: - async with connection.transaction(): - await connection.execute( - "alter table audit_events disable trigger audit_events_reject_update_delete" - ) - await connection.execute( - "alter table audit_events disable trigger audit_events_reject_truncate" - ) - await connection.execute("truncate table audit_events cascade") - await connection.execute("truncate table api_rate_control_counters") - await connection.execute( - "alter table actor_profiles disable trigger actor_profile_history_guard" - ) - await connection.execute( - "alter table actor_identity_links disable trigger actor_identity_link_history_guard" - ) - await connection.execute("truncate actor_identity_links, actor_profiles cascade") - await connection.execute( - "alter table actor_profiles enable trigger actor_profile_history_guard" - ) - await connection.execute( - "alter table actor_identity_links enable trigger actor_identity_link_history_guard" - ) - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_truncate" - ) - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_update_delete" - ) - finally: - await connection.close() - - @pytest.fixture def actor_database_env( monkeypatch: pytest.MonkeyPatch, postgres_database_url: str, migration_lock, + reset_test_database_state, ) -> Iterator[str]: """Run canonical actor tests against an isolated current schema.""" monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) @@ -102,15 +67,30 @@ def actor_database_env( get_settings.cache_clear() asyncio.run(db_session.dispose_engine()) config = alembic_config() - with migration_lock(): - command.downgrade(config, "base") - command.upgrade(config, "head") - yield postgres_database_url - asyncio.run(clear_test_audit_events(postgres_database_url)) - asyncio.run(db_session.dispose_engine()) - command.downgrade(config, "base") - asyncio.run(db_session.dispose_engine()) - get_settings.cache_clear() + try: + with migration_lock(): + command.downgrade(config, "base") + try: + command.upgrade(config, "head") + yield postgres_database_url + finally: + try: + asyncio.run( + reset_test_database_state( + postgres_database_url, + include_canonical_actors=True, + ) + ) + finally: + try: + asyncio.run(db_session.dispose_engine()) + finally: + command.downgrade(config, "base") + finally: + try: + asyncio.run(db_session.dispose_engine()) + finally: + get_settings.cache_clear() @pytest.fixture @@ -694,6 +674,76 @@ async def test_repeated_legacy_activation_updates_one_row_and_audits_only_change ) +async def test_concurrent_legacy_activation_serializes_payloads_and_actual_audits( + actor_database_env: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + actor = legacy_actor("legacy-concurrent-activation") + async with db_session.get_session_factory()() as session: + await ActorService(session).resolve_verified_actor( + verified_token("legacy-concurrent-activation"), + request_id=uuid4(), + correlation_id=uuid4(), + ) + + async with ( + db_session.get_session_factory()() as first_session, + db_session.get_session_factory()() as second_session, + ): + await ActorRepository(first_session).lock_external_identity( + actor.external_issuer, + actor.external_subject, + ) + original_lock = ActorRepository.lock_external_identity + second_lock_reached = asyncio.Event() + + async def observed_lock( + repository: ActorRepository, + issuer: str, + subject: str, + ) -> None: + if repository._session is second_session: + second_lock_reached.set() + await original_lock(repository, issuer, subject) + + monkeypatch.setattr(ActorRepository, "lock_external_identity", observed_lock) + second_activation = asyncio.create_task( + ActorService(second_session).activate_legacy_workflow_eligibility( + actor, + LegacyWorkflowEligibilityActivationRequest(skill_tags=["second"]), + ) + ) + await asyncio.wait_for(second_lock_reached.wait(), timeout=1) + assert not second_activation.done() + + first = await ActorService(first_session).activate_legacy_workflow_eligibility( + actor, + LegacyWorkflowEligibilityActivationRequest(skill_tags=["first"]), + ) + second = await second_activation + + assert first.skill_tags == ["first"] + assert second.skill_tags == ["second"] + assert first.id == second.id + async with db_session.get_session_factory()() as session: + eligibility = await session.scalar( + select(LegacyWorkflowEligibility).where( + LegacyWorkflowEligibility.actor_id == actor.actor_id + ) + ) + assert eligibility is not None + assert eligibility.skill_tags == ["second"] + assert ( + await session.scalar( + select(func.count()).select_from(AuditEvent).where( + AuditEvent.entity_type == "legacy_workflow_eligibility", + AuditEvent.actor_id == actor.actor_id, + ) + ) + == 2 + ) + + async def test_existing_actor_and_legacy_negative_states_fail_closed( actor_database_env: str, ) -> None: diff --git a/backend/tests/test_alembic.py b/backend/tests/test_alembic.py index df441fc3..04f0ca61 100644 --- a/backend/tests/test_alembic.py +++ b/backend/tests/test_alembic.py @@ -7,7 +7,7 @@ from pathlib import Path import threading import time -from uuid import uuid4 +from uuid import NAMESPACE_URL, uuid4, uuid5 import pytest from alembic import command @@ -332,10 +332,21 @@ def test_canonical_actor_classified_upgrade_preserves_identity_and_attribution( audit_event_id, ) ) + asyncio.run( + _update_canonical_actor_display_fields( + isolated_database_env, + actor_id, + display_name="Canonical Human", + contact_email=None, + ) + ) envelope_path.unlink() monkeypatch.delenv(CLASSIFICATION_FILE_ENV, raising=False) command.downgrade(config, "0019_authority_idempotency") restored = asyncio.run(_pre_0020_actor_state(isolated_database_env, actor_id)) + restored_display = asyncio.run( + _pre_0020_actor_display_fields(isolated_database_env, actor_id) + ) with pytest.raises( LegacyClassificationError, match="^classification_file_not_configured$", @@ -352,6 +363,9 @@ def test_canonical_actor_classified_upgrade_preserves_identity_and_attribution( "actor_kind": "human", "display_name": None, "contact_email": None, + "identity_link_id": str( + uuid5(NAMESPACE_URL, f"workstream:identity-link:{actor_id}") + ), "identity_subject": subject, "legacy_profile_type": "worker", "audit_actor_id": actor_id, @@ -359,6 +373,10 @@ def test_canonical_actor_classified_upgrade_preserves_identity_and_attribution( "source_checksum": envelope.source_row_set_sha256, } assert restored == {"revision": "0019_authority_idempotency", "legacy_rows": 1} + assert restored_display == { + "display_name": "Canonical Human", + "email": None, + } assert reupgrade_rejected == restored @@ -946,6 +964,54 @@ async def _pre_0020_actor_state(database_url: str, actor_id: str) -> dict[str, o await engine.dispose() +async def _pre_0020_actor_display_fields( + database_url: str, + actor_id: str, +) -> dict[str, str | None]: + """Return restored legacy display fields after canonical downgrade.""" + engine = create_async_engine(database_url) + try: + async with engine.connect() as connection: + row = ( + await connection.execute( + text( + "select display_name,email from actor_identities " + "where actor_id=:actor" + ), + {"actor": actor_id}, + ) + ).one() + return {"display_name": row.display_name, "email": row.email} + finally: + await engine.dispose() + + +async def _update_canonical_actor_display_fields( + database_url: str, + actor_id: str, + *, + display_name: str | None, + contact_email: str | None, +) -> None: + """Apply canonical self-service fields before downgrade proof.""" + engine = create_async_engine(database_url) + try: + async with engine.begin() as connection: + await connection.execute( + text( + "update actor_profiles set display_name=:display_name, " + "contact_email=:contact_email,updated_at=now() where id=:actor" + ), + { + "actor": actor_id, + "display_name": display_name, + "contact_email": contact_email, + }, + ) + finally: + await engine.dispose() + + async def _canonical_actor_migration_state( database_url: str, actor_id: str, @@ -964,13 +1030,15 @@ async def _canonical_actor_migration_state( {"actor": actor_id}, ) ).one() - identity_subject = await connection.scalar( - text( - "select subject from actor_identity_links " - "where actor_profile_id=:actor" - ), - {"actor": actor_id}, - ) + identity_link = ( + await connection.execute( + text( + "select id,subject from actor_identity_links " + "where actor_profile_id=:actor" + ), + {"actor": actor_id}, + ) + ).one() legacy_profile_type = await connection.scalar( text( "select profile_type from legacy_workflow_eligibility " @@ -995,7 +1063,8 @@ async def _canonical_actor_migration_state( "actor_kind": profile.actor_kind, "display_name": profile.display_name, "contact_email": profile.contact_email, - "identity_subject": identity_subject, + "identity_link_id": identity_link.id, + "identity_subject": identity_link.subject, "legacy_profile_type": legacy_profile_type, "audit_actor_id": audit_actor_id, "classified_count": migration_state.classified_count, @@ -1076,37 +1145,43 @@ async def _reset_canonical_actor_guard_state( ) -> None: """Restore test-owned state after proving the migration refuses it.""" engine = create_async_engine(database_url) + history_guard_disabled = False try: - if owner_reset: + try: + if owner_reset: + async with engine.begin() as connection: + await connection.execute( + text( + "alter table actor_profiles disable trigger " + "actor_profile_history_guard" + ) + ) + history_guard_disabled = True async with engine.begin() as connection: await connection.execute( text( - "alter table actor_profiles disable trigger actor_profile_history_guard" - ) + "update actor_profiles set status='active', suspended_by=null, " + "suspended_at=null, suspension_reason=null, deactivated_by=null, " + "deactivated_at=null, deactivation_reason=null where id=:actor" + ), + {"actor": actor_id}, ) - async with engine.begin() as connection: - await connection.execute( - text( - "update actor_profiles set status='active', suspended_by=null, " - "suspended_at=null, suspension_reason=null, deactivated_by=null, " - "deactivated_at=null, deactivation_reason=null where id=:actor" - ), - {"actor": actor_id}, - ) - await connection.execute( - text( - "update actor_identity_links set status='active', revoked_by=null, " - "revoked_at=null, revoked_reason=null where actor_profile_id=:actor" - ), - {"actor": actor_id}, - ) - if owner_reset: - async with engine.begin() as connection: await connection.execute( text( - "alter table actor_profiles enable trigger actor_profile_history_guard" - ) + "update actor_identity_links set status='active', revoked_by=null, " + "revoked_at=null, revoked_reason=null where actor_profile_id=:actor" + ), + {"actor": actor_id}, ) + finally: + if history_guard_disabled: + async with engine.begin() as connection: + await connection.execute( + text( + "alter table actor_profiles enable trigger " + "actor_profile_history_guard" + ) + ) finally: await engine.dispose() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index e80f04fe..920999d9 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,7 +1,7 @@ from __future__ import annotations +import ast import asyncio -import asyncpg import base64 import hashlib import hmac @@ -50,20 +50,73 @@ def _application_paths(app) -> set[str]: def test_legacy_submitter_eligibility_adapter_has_a_shrinking_static_allowlist() -> None: - """Keep the temporary bridge confined to its owner and three intake gates.""" + """Confine the temporary bridge to its owner, lifecycle view, and intake gates.""" app_root = Path(__file__).resolve().parents[1] / "app" - consumers = { - path.relative_to(app_root).as_posix() - for path in app_root.rglob("*.py") - if "LegacyWorkflowEligibilityCompatibility" in path.read_text() - } - task_service = (app_root / "modules/tasks/service.py").read_text() + compatibility_name = "LegacyWorkflowEligibilityCompatibility" + consumers: set[str] = set() + for path in app_root.rglob("*.py"): + tree = ast.parse(path.read_text(), filename=str(path)) + defines_compatibility = any( + isinstance(node, ast.ClassDef) and node.name == compatibility_name + for node in ast.walk(tree) + ) + imports_compatibility = any( + isinstance(node, ast.ImportFrom) + and any(alias.name == compatibility_name for alias in node.names) + for node in ast.walk(tree) + ) + calls_compatibility = any( + isinstance(node, ast.Call) + and ( + isinstance(node.func, ast.Name) + and node.func.id == compatibility_name + or isinstance(node.func, ast.Attribute) + and node.func.attr == compatibility_name + ) + for node in ast.walk(tree) + ) + if defines_compatibility or imports_compatibility or calls_compatibility: + consumers.add(path.relative_to(app_root).as_posix()) + + task_service_tree = ast.parse( + (app_root / "modules/tasks/service.py").read_text(), + filename="modules/tasks/service.py", + ) + compatibility_calls: list[tuple[str, str]] = [] + task_service_class = next( + node + for node in task_service_tree.body + if isinstance(node, ast.ClassDef) and node.name == "TaskService" + ) + for method in task_service_class.body: + if not isinstance(method, ast.AsyncFunctionDef | ast.FunctionDef): + continue + compatibility_calls.extend( + (method.name, node.func.attr) + for node in ast.walk(method) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr + in { + "_require_legacy_submitter_eligibility", + "get_active_submitter_eligibility", + } + ) assert consumers == { "modules/actors/service.py", "modules/tasks/service.py", } - assert task_service.count("_require_legacy_submitter_eligibility(actor)") == 3 + assert sorted(compatibility_calls) == [ + ( + "_require_legacy_submitter_eligibility", + "get_active_submitter_eligibility", + ), + ("claim_task", "_require_legacy_submitter_eligibility"), + ("create_submission", "_require_legacy_submitter_eligibility"), + ("get_task_work_context", "get_active_submitter_eligibility"), + ("start_task", "_require_legacy_submitter_eligibility"), + ] @pytest.fixture(autouse=True) @@ -75,34 +128,12 @@ def clear_settings_cache() -> None: clear_auth_verifier_cache() -async def clear_test_audit_events(database_url: str) -> None: - """Reset append-only evidence under explicit isolated-test ownership.""" - connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) - try: - async with connection.transaction(): - await connection.execute( - "alter table audit_events disable trigger audit_events_reject_update_delete" - ) - await connection.execute( - "alter table audit_events disable trigger audit_events_reject_truncate" - ) - await connection.execute("truncate table audit_events cascade") - await connection.execute("truncate table api_rate_control_counters") - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_truncate" - ) - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_update_delete" - ) - finally: - await connection.close() - - @pytest.fixture def auth_database_env( monkeypatch: pytest.MonkeyPatch, postgres_database_url: str, migration_lock, + reset_test_database_state, ) -> Iterator[str]: """Run auth route persistence tests against a migrated Postgres schema.""" monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) @@ -116,15 +147,25 @@ def auth_database_env( project_root = Path(__file__).resolve().parents[1] config = Config(str(project_root / "alembic.ini")) config.set_main_option("script_location", str(project_root / "alembic")) - with migration_lock(): - command.downgrade(config, "base") - command.upgrade(config, "head") - yield postgres_database_url - asyncio.run(clear_test_audit_events(postgres_database_url)) - asyncio.run(db_session.dispose_engine()) - command.downgrade(config, "base") - asyncio.run(db_session.dispose_engine()) - get_settings.cache_clear() + try: + with migration_lock(): + command.downgrade(config, "base") + try: + command.upgrade(config, "head") + yield postgres_database_url + finally: + try: + asyncio.run(reset_test_database_state(postgres_database_url)) + finally: + try: + asyncio.run(db_session.dispose_engine()) + finally: + command.downgrade(config, "base") + finally: + try: + asyncio.run(db_session.dispose_engine()) + finally: + get_settings.cache_clear() def _base64url_int(value: int) -> str: @@ -310,6 +351,37 @@ async def test_flow_auth_rejects_subject_above_persisted_identity_bound() -> Non await verifier.verify(token) +@pytest.mark.parametrize("subject", [" ", " padded-subject "]) +async def test_flow_auth_rejects_subject_whitespace_before_persistence(subject: str) -> None: + secret = "local-test-secret" + now = int(datetime.now(UTC).timestamp()) + verifier = FlowAuthVerifier( + Settings( + environment="test", + auth_provider="flow", + flow_auth_issuer="https://issuer.local.test", + flow_auth_audience="workstream", + flow_auth_local_hmac_secret=secret, + ) + ) + token = issue_local_hmac_token( + secret, + { + "iss": "https://issuer.local.test", + "sub": subject, + "aud": "workstream", + "exp": now + 300, + "iat": now, + "jti": "whitespace-subject", + "subject_kind": "human", + "scope": "workstream:access", + }, + ) + + with pytest.raises(AuthVerificationError, match="token subject is required"): + await verifier.verify(token) + + @pytest.mark.parametrize("environment", ["staging", "preview", "prod", "production"]) def test_local_hmac_fixture_is_impossible_in_production(environment: str) -> None: with pytest.raises(RuntimeError, match="cannot run outside local/test"): @@ -1526,6 +1598,45 @@ def test_dev_auth_requires_explicit_identity_fields( DevelopmentAuthVerifier(settings) +@pytest.mark.parametrize( + ("field_name", "error_message"), + [ + ("dev_auth_subject", "WORKSTREAM_DEV_AUTH_SUBJECT must be set"), + ("dev_auth_issuer", "WORKSTREAM_DEV_AUTH_ISSUER must be set"), + ], +) +def test_dev_auth_rejects_whitespace_only_identity_anchors( + field_name: str, + error_message: str, +) -> None: + values = { + "environment": "local", + "auth_provider": "dev", + "dev_auth_token": "local-token", + "dev_auth_subject": "subject", + "dev_auth_issuer": "issuer", + } + values[field_name] = " \t " + + with pytest.raises(RuntimeError, match=error_message): + DevelopmentAuthVerifier(Settings(**values)) + + +@pytest.mark.parametrize("field_name", ["dev_auth_subject", "dev_auth_issuer"]) +def test_dev_auth_rejects_surrounding_identity_anchor_whitespace(field_name: str) -> None: + values = { + "environment": "local", + "auth_provider": "dev", + "dev_auth_token": "local-token", + "dev_auth_subject": "subject", + "dev_auth_issuer": "issuer", + } + values[field_name] = f" {values[field_name]} " + + with pytest.raises(RuntimeError, match=f"WORKSTREAM_{field_name.upper()}"): + DevelopmentAuthVerifier(Settings(**values)) + + async def test_flow_auth_verifier_boundary_rejects_unconfigured_verification() -> None: with pytest.raises(RuntimeError, match="WORKSTREAM_TOKEN_ISSUER"): FlowAuthVerifier(Settings(auth_provider="flow")) diff --git a/backend/tests/test_checkers.py b/backend/tests/test_checkers.py index a9e1c69a..ef57f550 100644 --- a/backend/tests/test_checkers.py +++ b/backend/tests/test_checkers.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import asyncpg from collections.abc import AsyncIterator, Iterator from pathlib import Path from typing import Any @@ -58,34 +57,12 @@ ) -async def clear_test_audit_events(database_url: str) -> None: - """Reset append-only evidence under explicit isolated-test ownership.""" - connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) - try: - async with connection.transaction(): - await connection.execute( - "alter table audit_events disable trigger audit_events_reject_update_delete" - ) - await connection.execute( - "alter table audit_events disable trigger audit_events_reject_truncate" - ) - await connection.execute("truncate table audit_events cascade") - await connection.execute("truncate table api_rate_control_counters") - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_truncate" - ) - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_update_delete" - ) - finally: - await connection.close() - - @pytest.fixture def checker_database_env( monkeypatch: pytest.MonkeyPatch, postgres_database_url: str, migration_lock, + reset_test_database_state, ) -> Iterator[str]: monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") @@ -98,15 +75,25 @@ def checker_database_env( asyncio.run(db_session.dispose_engine()) config = alembic_config() - with migration_lock(): - command.downgrade(config, "base") - command.upgrade(config, "head") - yield postgres_database_url - asyncio.run(clear_test_audit_events(postgres_database_url)) - asyncio.run(db_session.dispose_engine()) - command.downgrade(config, "base") - asyncio.run(db_session.dispose_engine()) - get_settings.cache_clear() + try: + with migration_lock(): + command.downgrade(config, "base") + try: + command.upgrade(config, "head") + yield postgres_database_url + finally: + try: + asyncio.run(reset_test_database_state(postgres_database_url)) + finally: + try: + asyncio.run(db_session.dispose_engine()) + finally: + command.downgrade(config, "base") + finally: + try: + asyncio.run(db_session.dispose_engine()) + finally: + get_settings.cache_clear() @pytest.fixture diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py index 8f77acc5..46db6b58 100644 --- a/backend/tests/test_projects.py +++ b/backend/tests/test_projects.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import asyncpg import hashlib import inspect import json @@ -82,34 +81,12 @@ ) -async def clear_test_audit_events(database_url: str) -> None: - """Reset append-only evidence under explicit isolated-test ownership.""" - connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) - try: - async with connection.transaction(): - await connection.execute( - "alter table audit_events disable trigger audit_events_reject_update_delete" - ) - await connection.execute( - "alter table audit_events disable trigger audit_events_reject_truncate" - ) - await connection.execute("truncate table audit_events cascade") - await connection.execute("truncate table api_rate_control_counters") - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_truncate" - ) - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_update_delete" - ) - finally: - await connection.close() - - @pytest.fixture def project_database_env( monkeypatch: pytest.MonkeyPatch, postgres_database_url: str, migration_lock, + reset_test_database_state, ) -> Iterator[str]: monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) monkeypatch.setenv( @@ -130,15 +107,25 @@ def project_database_env( project_root = Path(__file__).resolve().parents[1] config = Config(str(project_root / "alembic.ini")) config.set_main_option("script_location", str(project_root / "alembic")) - with migration_lock(): - command.downgrade(config, "base") - command.upgrade(config, "head") - yield postgres_database_url - asyncio.run(clear_test_audit_events(postgres_database_url)) - asyncio.run(db_session.dispose_engine()) - command.downgrade(config, "base") - asyncio.run(db_session.dispose_engine()) - get_settings.cache_clear() + try: + with migration_lock(): + command.downgrade(config, "base") + try: + command.upgrade(config, "head") + yield postgres_database_url + finally: + try: + asyncio.run(reset_test_database_state(postgres_database_url)) + finally: + try: + asyncio.run(db_session.dispose_engine()) + finally: + command.downgrade(config, "base") + finally: + try: + asyncio.run(db_session.dispose_engine()) + finally: + get_settings.cache_clear() @pytest.fixture diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index ec0bf22d..a5386a78 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import asyncpg import hashlib import json from collections.abc import AsyncIterator, Iterator @@ -15,7 +14,7 @@ from alembic import command from alembic.config import Config from httpx import ASGITransport, AsyncClient -from sqlalchemy import inspect, select, text +from sqlalchemy import func, inspect, select, text from sqlalchemy.dialects import postgresql from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -65,29 +64,6 @@ from app.schemas.auth import ActorContext -async def clear_test_audit_events(database_url: str) -> None: - """Reset append-only evidence under explicit isolated-test ownership.""" - connection = await asyncpg.connect(database_url.replace("+asyncpg", "")) - try: - async with connection.transaction(): - await connection.execute( - "alter table audit_events disable trigger audit_events_reject_update_delete" - ) - await connection.execute( - "alter table audit_events disable trigger audit_events_reject_truncate" - ) - await connection.execute("truncate table audit_events cascade") - await connection.execute("truncate table api_rate_control_counters") - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_truncate" - ) - await connection.execute( - "alter table audit_events enable trigger audit_events_reject_update_delete" - ) - finally: - await connection.close() - - async def test_task_repository_delegates_audit_persistence() -> None: """Keep legacy task audit methods as same-session shared-writer adapters.""" session = MagicMock() @@ -134,6 +110,7 @@ def task_database_env( monkeypatch: pytest.MonkeyPatch, postgres_database_url: str, migration_lock, + reset_test_database_state, ) -> Iterator[str]: monkeypatch.setenv("WORKSTREAM_DATABASE_URL", postgres_database_url) monkeypatch.setenv("WORKSTREAM_CELERY_TASK_ALWAYS_EAGER", "true") @@ -146,15 +123,25 @@ def task_database_env( asyncio.run(db_session.dispose_engine()) config = alembic_config() - with migration_lock(): - command.downgrade(config, "base") - command.upgrade(config, "head") - yield postgres_database_url - asyncio.run(clear_test_audit_events(postgres_database_url)) - asyncio.run(db_session.dispose_engine()) - command.downgrade(config, "base") - asyncio.run(db_session.dispose_engine()) - get_settings.cache_clear() + try: + with migration_lock(): + command.downgrade(config, "base") + try: + command.upgrade(config, "head") + yield postgres_database_url + finally: + try: + asyncio.run(reset_test_database_state(postgres_database_url)) + finally: + try: + asyncio.run(db_session.dispose_engine()) + finally: + command.downgrade(config, "base") + finally: + try: + asyncio.run(db_session.dispose_engine()) + finally: + get_settings.cache_clear() @pytest.fixture @@ -2142,6 +2129,12 @@ async def test_worker_without_profile_cannot_claim_ready_task( assert response.status_code == 403 assert "active legacy submitter eligibility" in response.json()["detail"] + context = await task_client.get( + f"/api/v1/tasks/{ready_task['id']}/work-context", + headers=auth_headers(), + ) + assert context.status_code == 200, context.text + assert context.json()["lifecycle"]["next_actions"] == [] async def test_disabled_worker_profile_cannot_claim_ready_task( @@ -2213,6 +2206,84 @@ async def test_disabled_legacy_eligibility_after_claim_blocks_assigned_submitter ) assert read.status_code == 200 assert read.json()["status"] == "claimed" + context = await task_client.get( + f"/api/v1/tasks/{ready_task['id']}/work-context", + headers=auth_headers(), + ) + assert context.status_code == 200, context.text + assert context.json()["lifecycle"]["next_actions"] == [] + + +async def test_disabled_eligibility_suppresses_submit_lifecycle_affordances( + task_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project = await create_active_project(task_client) + subject = "eligibility-disabled-after-start" + started_task = await create_started_task( + task_client, + project["id"], + monkeypatch, + subject=subject, + ) + async with db_session.get_session_factory()() as session: + eligibility = await session.scalar( + select(LegacyWorkflowEligibility).where( + LegacyWorkflowEligibility.actor_id == actor_id(subject), + LegacyWorkflowEligibility.profile_type == "worker", + ) + ) + assert eligibility is not None + eligibility.status = "disabled" + await session.commit() + + context = await task_client.get( + f"/api/v1/tasks/{started_task['id']}/work-context", + headers=auth_headers(), + ) + + assert context.status_code == 200, context.text + lifecycle = context.json()["lifecycle"] + assert lifecycle["can_run_pre_submit_check"] is False + assert lifecycle["can_submit"] is False + assert lifecycle["next_actions"] == [] + + async with db_session.get_session_factory()() as session: + before = { + "submissions": await session.scalar( + select(func.count()).select_from(Submission).where( + Submission.task_id == started_task["id"] + ) + ), + "checker_runs": await session.scalar( + select(func.count()).select_from(db_models.CheckerRun) + ), + "audit_events": await session.scalar( + select(func.count()).select_from(AuditEvent) + ), + } + submission = await task_client.post( + f"/api/v1/tasks/{started_task['id']}/submissions", + headers=auth_headers(), + json=complete_submission_payload(), + ) + assert submission.status_code == 403 + assert "active legacy submitter eligibility" in submission.json()["detail"] + async with db_session.get_session_factory()() as session: + after = { + "submissions": await session.scalar( + select(func.count()).select_from(Submission).where( + Submission.task_id == started_task["id"] + ) + ), + "checker_runs": await session.scalar( + select(func.count()).select_from(db_models.CheckerRun) + ), + "audit_events": await session.scalar( + select(func.count()).select_from(AuditEvent) + ), + } + assert after == before async def test_active_worker_profile_without_worker_token_cannot_claim( @@ -2232,6 +2303,12 @@ async def test_active_worker_profile_without_worker_token_cannot_claim( assert response.status_code == 403 assert "actor lacks required role" in response.json()["detail"] + context = await task_client.get( + f"/api/v1/tasks/{ready_task['id']}/work-context", + headers=auth_headers(), + ) + assert context.status_code == 200, context.text + assert context.json()["lifecycle"]["next_actions"] == [] @pytest.mark.parametrize("profile_type", ["admin", "project_manager"]) diff --git a/docs/operations_authorization_service.md b/docs/operations_authorization_service.md index 46f20775..f2fcc146 100644 --- a/docs/operations_authorization_service.md +++ b/docs/operations_authorization_service.md @@ -452,11 +452,12 @@ cd backend .venv/bin/alembic downgrade 0019_authority_idempotency ``` -Rollback restores every new canonical identity to legacy identity storage. -For identities that already existed before AUTH-06, conflict handling preserves -their legacy display name and email; it does not backport later canonical -`PATCH /api/v1/actors/me` display edits into those legacy fields. This is an -intentional privacy boundary, not a synchronization mechanism. +Rollback restores every canonical identity to legacy identity storage and +copies the current canonical `display_name` and `contact_email`, including +`null`, into the restored row. This prevents a cleared canonical contact email +from being resurrected by pre-AUTH-06 code. Because canonical migration does +not import legacy display fields, rollback intentionally scrubs retained legacy +display data unless it was set through `PATCH /api/v1/actors/me` after AUTH-06. ## Staged Rollout From abd76c995e51645b61d4d3ac07f1ff82ab6eb740 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 15 Jul 2026 07:33:48 +0100 Subject: [PATCH 4/7] Complete AUTH-06 external review repairs --- .agent-loop/merge-intents/WS-AUTH-001-06.json | 2 +- backend/app/modules/tasks/schemas.py | 18 +++++++++--------- backend/app/modules/tasks/service.py | 4 ++-- backend/tests/test_tasks.py | 14 ++++++++++++-- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/.agent-loop/merge-intents/WS-AUTH-001-06.json b/.agent-loop/merge-intents/WS-AUTH-001-06.json index 70a5996b..71b1b6f9 100644 --- a/.agent-loop/merge-intents/WS-AUTH-001-06.json +++ b/.agent-loop/merge-intents/WS-AUTH-001-06.json @@ -5,5 +5,5 @@ "next_chunk_id": "WS-AUTH-001-07", "next_chunk_title": "Authorization Kernel And Permission Registry", "next_requires_explicit_start": true, - "schema_version": 1 + "schema_version": 2 } diff --git a/backend/app/modules/tasks/schemas.py b/backend/app/modules/tasks/schemas.py index 6fd558ec..e7372210 100644 --- a/backend/app/modules/tasks/schemas.py +++ b/backend/app/modules/tasks/schemas.py @@ -120,7 +120,7 @@ class EvidenceItemCreate(BaseModel): class ArtifactHashEntry(BaseModel): - """Structured artifact hash entry supplied by a worker.""" + """Structured artifact hash entry supplied by a Contributor.""" model_config = ConfigDict(extra="forbid") @@ -187,7 +187,7 @@ class TaskResponse(BaseModel): class TaskProjectContext(BaseModel): - """Worker-safe project summary for a task context response.""" + """Contributor-safe project summary for a task context response.""" id: str name: str @@ -196,7 +196,7 @@ class TaskProjectContext(BaseModel): class TaskWorkerTaskContext(BaseModel): - """Worker-safe task summary for work-context responses.""" + """Contributor-safe task summary for work-context responses.""" id: str project_id: str @@ -219,7 +219,7 @@ class TaskWorkerTaskContext(BaseModel): class TaskGuideContext(BaseModel): - """Worker-safe guide material locked to a task.""" + """Contributor-safe guide material locked to a task.""" id: str version: str @@ -229,19 +229,19 @@ class TaskGuideContext(BaseModel): class TaskReviewPolicyContext(BaseModel): - """Worker-safe review policy summary for the locked guide version.""" + """Contributor-safe review policy summary for the locked guide version.""" guide_version: str class TaskRevisionPolicyContext(BaseModel): - """Worker-safe revision policy summary for the locked guide version.""" + """Contributor-safe revision policy summary for the locked guide version.""" guide_version: str class TaskPaymentPolicyContext(BaseModel): - """Worker-safe payment terms stamped onto the task at screening.""" + """Contributor-safe payment terms stamped onto the task at screening.""" guide_version: str base_amount: Decimal | None @@ -260,7 +260,7 @@ class TaskWorkerLifecycleContext(BaseModel): class TaskWorkContextResponse(BaseModel): - """Worker-safe context needed before doing task work.""" + """Contributor-safe context needed before doing task work.""" task: TaskWorkerTaskContext project: TaskProjectContext @@ -312,7 +312,7 @@ class StorageReferenceRules(BaseModel): class SubmissionRequirementsResponse(BaseModel): - """Worker-safe exact submission requirements for a locked task.""" + """Contributor-safe exact submission requirements for a locked task.""" task_id: str project_id: str diff --git a/backend/app/modules/tasks/service.py b/backend/app/modules/tasks/service.py index 65b69b63..f8df216e 100644 --- a/backend/app/modules/tasks/service.py +++ b/backend/app/modules/tasks/service.py @@ -175,7 +175,7 @@ class TaskValidationError(TaskServiceError): class TaskAssignmentConflict(TaskServiceError): - """Raised when a task already has an active worker assignment.""" + """Raised when a task already has an active Contributor assignment.""" status_code = 409 @@ -391,7 +391,7 @@ async def get_task_submission_requirements( actor: ActorContext, task_id: str, ) -> SubmissionRequirementsResponse: - """Return exact worker submission requirements for a locked task. + """Return exact Contributor submission requirements for a locked task. Args: actor: Verified Flow actor context for the current request. diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index a5386a78..34999bcf 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -2292,8 +2292,18 @@ async def test_active_worker_profile_without_worker_token_cannot_claim( ) -> None: project = await create_active_project(task_client) ready_task = await create_ready_task(task_client, project["id"]) - await seed_worker_profile("worker-role-missing") - set_dev_actor(monkeypatch, roles="project_manager", subject="worker-role-missing") + set_dev_actor(monkeypatch, roles="worker", subject="project-manager-subject") + activation = await task_client.post( + "/api/v1/workers/me/profile", + headers=auth_headers(), + json={"skill_tags": []}, + ) + assert activation.status_code == 200, activation.text + set_dev_actor( + monkeypatch, + roles="project_manager", + subject="project-manager-subject", + ) response = await task_client.post( f"/api/v1/tasks/{ready_task['id']}/claim", From dd024e53995ca80c0aa45d6bff8f86776e6be952 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 15 Jul 2026 07:35:28 +0100 Subject: [PATCH 5/7] Record AUTH-06 external review response --- ...WS-AUTH-001-06-external-review-response.md | 88 +++++++++++++++++++ ...WS-AUTH-001-06-internal-review-evidence.md | 37 ++++---- .../reviews/WS-AUTH-001-06-pr-trust-bundle.md | 25 ++++-- 3 files changed, 124 insertions(+), 26 deletions(-) create mode 100644 .agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-external-review-response.md diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-external-review-response.md new file mode 100644 index 00000000..890b6d95 --- /dev/null +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-external-review-response.md @@ -0,0 +1,88 @@ +# External Review Response: WS-AUTH-001-06 + +## Pull Request + +PR #124 - Add canonical actor profile and identity resolution + +CodeRabbit run: `099fb3fe-cd93-453a-9445-e0e483fe3b79` + +## Comments Addressed + +1. Migration identity-link UUIDs now seed from the canonical actor UUID instead + of delimiter-ambiguous issuer/subject concatenation. +2. Downgrade copies current canonical display fields, including `null`, into + existing legacy rows so a cleared contact email cannot be resurrected. The + migration proof covers an updated display name and cleared email, and the + operations contract records the intentional rollback scrubbing behavior. +3. Development and Flow verifier boundaries reject blank or padded identity + anchors before persistence without normalizing distinct opaque values. +4. Legacy eligibility activation takes the exact external-identity advisory + lock. The synchronized concurrency proof establishes actual lock waiting, + ordered payload application, one row, and one audit per real transition. +5. Work-context claim, start, pre-submit-check, and submit affordances require + both the current compatibility role and active eligibility, matching the + mutation guards. Tests prove absent, disabled, and role-missing behavior plus + submission denial with no submission, checker-run, or audit side effects. +6. The shared privileged database reset now has one test-owned implementation + with an explicit canonical-actor option. All five affected fixture teardowns + use nested `finally` blocks so reset failure cannot skip disposal or Alembic + downgrade. +7. Canonical actor history-trigger reset tracks successful disable and always + re-enables the trigger in a fresh transaction. +8. The compatibility allowlist uses Python AST structure and binds every direct + adapter read and eligibility-gate call to its exact `TaskService` method. +9. The live API contract asserts that issuer, subject, and legacy roles are + absent from the canonical actor response. +10. Touched Contributor-facing task prose no longer describes a human actor as + a worker; legacy wire and storage identifiers remain under their declared + later cutover. + +## Comments Deferred + +None of the actionable comments were deferred. + +## Non-Actionable Review Output + +CodeRabbit's 53.8 percent docstring warning is a diff-local advisory rather than +the repository's configured quality gate. Existing public behavior and complex +helpers retain useful docstrings; narration-only comments were not added. + +## Human Decisions Needed + +None. The repaired downgrade now favors current canonical privacy state over +retaining stale pre-AUTH-06 display data, and the runbook makes that rollback +tradeoff explicit. + +## Commands Rerun + +```text +pytest focused AUTH-06 migration/concurrency/lifecycle behavior +pytest test_actor_legacy_classification.py test_actors.py --cov=app.modules.actors --cov-branch +python backend/scripts/api_contract_e2e.py +ruff check app tests alembic/versions/0020_canonical_actor_profile.py scripts/api_contract_e2e.py +python3 scripts/test_agent_gates.py +python3 scripts/update_post_merge_memory.py validate-merge-intent --base-ref origin/main +python3 scripts/check_loop_memory_state.py +python3 scripts/check_stale_workstream_wording.py +python3 scripts/check_stale_authorization_docs.py +python3 scripts/check_markdown_links.py +git diff --check +``` + +- Actor/classification behavior: 83 passed at 90.1031 percent branch coverage. +- Real Postgres API contract: passed through migration `0020` and the complete + current task intake drill. +- Integrated engineering-loop gates: 71 passed; schema-v2 merge intent passed. +- All required internal reviewer tracks passed on reviewed SHA + `abd76c995e51645b61d4d3ac07f1ff82ab6eb740`. + +## Remaining Risks + +- Production migration still requires the documented quiesced deployment and + non-owner runtime role. +- Rollback intentionally scrubs retained legacy display data that was not set + canonically after AUTH-06. +- GitHub Backend owns the repository-wide suite and 78 percent baseline. + +No GitHub thread is replied to or resolved by this evidence file. Thread writes +remain a separate explicit action after the repaired commit is pushed. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md index 909102e9..8ed10829 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md @@ -1,8 +1,8 @@ # WS-AUTH-001-06 Internal Review Evidence -Reviewed code SHA: `13cd665ffc683d59e83bca70f370080067ef3fe9` -Reviewed runtime SHA: `13cd665ffc683d59e83bca70f370080067ef3fe9` -Reviewed at: `2026-07-15T05:20:21Z` +Reviewed code SHA: `abd76c995e51645b61d4d3ac07f1ff82ab6eb740` +Reviewed runtime SHA: `abd76c995e51645b61d4d3ac07f1ff82ab6eb740` +Reviewed at: `2026-07-15T06:34:00Z` Reviewer run IDs: `auth06_final_senior`, `auth06_qa_review`, `auth06_security_review`, `auth06_product_ops_review`, `auth06_final_architecture`, `auth06_final_ci`, `auth06_final_docs`, @@ -10,24 +10,22 @@ Reviewer run IDs: `auth06_final_senior`, `auth06_qa_review`, ## Deterministic Evidence -- Actor subsystem branch coverage reached 90.0929 percent across 803 +- Actor subsystem branch coverage reached 90.1031 percent across 804 statements and 166 branches, above the required 90 percent gate. -- The broad actor, authentication, task, project, checker, rate-control, and - migration coverage run completed 186 behavior tests before one static - allowlist assertion exposed the new shared rate-control module. The allowlist - was repaired, its focused proof passed, and three additional behavior tests - strengthened repeated access, unavailable rate control, and idempotent legacy - activation before the final coverage report. -- High-risk migration rerun: 3 passed in 57.32 seconds, covering terminal-state - downgrade refusal, privacy-safe invalid-row handling, and classified identity - and attribution preservation. +- Final actor and legacy-classification coverage run: 83 passed in 706.29 + seconds, including synchronized compatibility activation, rollback, privacy, + exact-anchor, and lifecycle behavior. +- External-review database proof passed six migration, concurrency, and + lifecycle scenarios together; the repaired role-without-submit-access case + then passed separately against the integrated branch. - Real Postgres API contract passed through migration `0020`, actor - provisioning, compatibility projection, task claim/start/submission, and - authorization denials. + provisioning, response privacy, compatibility projection, task + claim/start/submission, and authorization denials. - Ruff passed for backend application, tests, migration, and API contract code. - Stale Workstream wording, stale authorization documentation, changed Markdown links, and `git diff --check` passed. -- All 63 engineering-loop agent-gate tests passed. +- All 71 integrated engineering-loop agent-gate tests passed, and the schema-v2 + merge intent resolves the exact same-initiative AUTH-07 successor. - No workflow, dependency, coverage threshold, skip, exclusion, or package script changed. GitHub Backend remains authoritative for the repository-wide 78 percent floor; the multi-hour full suite was not repeated locally. @@ -51,9 +49,10 @@ Reviewer run IDs: `auth06_final_senior`, `auth06_qa_review`, Review repair closed stale eligibility on assigned task start, operator-override regression, deactivated-profile access, non-human zero-write proof, rate-control unavailability, mid-audit rollback, repeated-access timestamp behavior, -idempotent compatibility activation, migration index and downgrade gaps, -invalid-row privacy, claim-bound inconsistency, duplicate error mapping, and -active-document legacy terminology. +idempotent and serialized compatibility activation, migration ID/downgrade +gaps, invalid-row privacy, exact-anchor validation, lifecycle affordance drift, +fixture cleanup safety, duplicate error mapping, and human-facing legacy +terminology. Valid findings addressed: yes diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md index 35ebba73..675c82bd 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md @@ -42,7 +42,7 @@ activated. ## Design Chosen -External identity is keyed only by normalized issuer and subject. Human profile +External identity is keyed only by exact, bounded issuer and subject. Human profile state is separate from identity links and defaults to active without assigning a role. First creation is serialized with a PostgreSQL advisory transaction lock. Runtime repositories do not own commits or sessions, and authority events @@ -84,19 +84,26 @@ authorization remains unchanged until the grant and evaluator cutovers. provenance. - Migration upgrade, classification, constraints, indexes, privacy, guarded downgrade, re-upgrade, and terminal deactivation behavior have direct tests. +- Downgrade copies current canonical display fields, including a cleared email, + so pre-AUTH-06 code cannot resurrect stale private data. - Rate-control and audit failures roll back actor state and evidence together. -- Legacy activation is idempotent and audits only actual changes. +- Legacy activation is serialized and idempotent and audits only actual changes. +- Work-context claim, start, and submit affordances require the same current + compatibility role and active eligibility as their mutation gates. ## Tests And Checks Run -- Actor subsystem branch coverage: 90.0929 percent. -- High-risk migration rerun: 3 passed in 57.32 seconds. +- Actor subsystem branch coverage: 90.1031 percent across 804 statements and + 166 branches; 83 actor/classification behavior tests passed in 706.29 seconds. +- External-review migration, concurrency, lifecycle, exact-anchor, privacy, and + zero-side-effect scenarios passed on the integrated branch. - Real Postgres API contract: passed end to end through migration `0020`. - Focused actor/auth/task/project/checker/rate-control behavior and migration suites passed; final coverage data includes the repaired static inventory and added repeated-access, unavailable-control, and compatibility assertions. - Ruff, stale wording, stale authorization docs, changed Markdown links, diff - integrity, and all 63 engineering-loop agent-gate tests passed. + integrity, all 71 engineering-loop agent-gate tests, loop-memory state, and + the schema-v2 AUTH-06 merge-intent validator passed. - The multi-hour repository suite was not repeated locally. GitHub Backend owns the authoritative full-suite result and repository-wide 78 percent floor. @@ -115,13 +122,17 @@ still enforce the repository-wide 78 percent baseline. ## Reviewer Results -Exact reviewed SHA `13cd665ffc683d59e83bca70f370080067ef3fe9` passed senior +Exact reviewed SHA `abd76c995e51645b61d4d3ac07f1ff82ab6eb740` passed senior engineering, QA/test, security/auth, product/ops, architecture, CI integrity, docs, reuse/dedup, and test-delta review with no blocking findings. ## External Review -Pending GitHub checks, CodeRabbit, and human review. +CodeRabbit's seven inline findings and three nitpicks were triaged. All valid +runtime, migration, lifecycle, privacy, test-harness, and test-contract findings +were repaired. Its generic diff-local docstring percentage is not the +repository's configured gate and required no narration-only churn. GitHub checks, +the next CodeRabbit head, and human review remain pending. ## Remaining Risks From 25d9455f6dca41b207a0ba3aaba8de9cc2683a17 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 15 Jul 2026 09:34:54 +0100 Subject: [PATCH 6/7] Fix OpenAPI inventory contract for actor routes --- backend/tests/test_api_controls.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_api_controls.py b/backend/tests/test_api_controls.py index f80697dd..3b6f0f94 100644 --- a/backend/tests/test_api_controls.py +++ b/backend/tests/test_api_controls.py @@ -439,13 +439,13 @@ def test_openapi_documents_request_error_and_response_context() -> None: for method, operation in path_item.items() if method in methods and operation.get("security") ) - assert len(route_inventory) == 46 + assert len(route_inventory) == 48 assert sha256("\n".join(route_inventory).encode()).hexdigest() == ( - "991f50d0dd6009e96c1cd8d0a8b6f403d6f48d81bb247075c103a9d74341425b" + "fa394a491b7e24f53f373e7ff54f4699d72d04a1ab88c79b53f70ffb48f2592e" ) - assert len(protected_inventory) == 44 + assert len(protected_inventory) == 46 assert sha256("\n".join(protected_inventory).encode()).hexdigest() == ( - "ae15e39df8b1710e16b9c20cfa588a8ee8f96a98505f34e1cbcc5fe4c96a17d4" + "faa2176c8b222a6e3ae216b5d1dfd1c1b5a4c436045b13d3d2d8e7de5818706e" ) assert set(schema["paths"]["/health"]["get"]["responses"]) == {"200", "400", "500"} assert {"401", "403", "503"} <= set( From 4a2193fd91ff78cde6c8f64a12f9e8f2211640e6 Mon Sep 17 00:00:00 2001 From: Abiorh001 Date: Wed, 15 Jul 2026 09:36:32 +0100 Subject: [PATCH 7/7] Record AUTH-06 Backend CI repair --- .../WS-AUTH-001-06-external-review-response.md | 10 ++++++++-- .../WS-AUTH-001-06-internal-review-evidence.md | 15 ++++++++++++--- .../reviews/WS-AUTH-001-06-pr-trust-bundle.md | 9 +++++++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-external-review-response.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-external-review-response.md index 890b6d95..94418433 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-external-review-response.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-external-review-response.md @@ -36,6 +36,9 @@ CodeRabbit run: `099fb3fe-cd93-453a-9445-e0e483fe3b79` 10. Touched Contributor-facing task prose no longer describes a human actor as a worker; legacy wire and storage identifiers remain under their declared later cutover. +11. GitHub Backend's only failure was the strict OpenAPI inventory retaining the + pre-AUTH-06 counts and hashes. The contract now includes the two protected + actor self-service operations without relaxing either fingerprint. ## Comments Deferred @@ -58,6 +61,7 @@ tradeoff explicit. ```text pytest focused AUTH-06 migration/concurrency/lifecycle behavior pytest test_actor_legacy_classification.py test_actors.py --cov=app.modules.actors --cov-branch +pytest test_api_controls.py python backend/scripts/api_contract_e2e.py ruff check app tests alembic/versions/0020_canonical_actor_profile.py scripts/api_contract_e2e.py python3 scripts/test_agent_gates.py @@ -73,8 +77,10 @@ git diff --check - Real Postgres API contract: passed through migration `0020` and the complete current task intake drill. - Integrated engineering-loop gates: 71 passed; schema-v2 merge intent passed. -- All required internal reviewer tracks passed on reviewed SHA - `abd76c995e51645b61d4d3ac07f1ff82ab6eb740`. +- GitHub Backend: 983 passed, one repaired OpenAPI inventory failure, and 83.11 + percent repository coverage; all 27 API-control tests pass after repair. +- All required internal reviewer tracks passed on reviewed code SHA + `25d9455f6dca41b207a0ba3aaba8de9cc2683a17`. ## Remaining Risks diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md index 8ed10829..d9aeb1cf 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-internal-review-evidence.md @@ -1,8 +1,8 @@ # WS-AUTH-001-06 Internal Review Evidence -Reviewed code SHA: `abd76c995e51645b61d4d3ac07f1ff82ab6eb740` +Reviewed code SHA: `25d9455f6dca41b207a0ba3aaba8de9cc2683a17` Reviewed runtime SHA: `abd76c995e51645b61d4d3ac07f1ff82ab6eb740` -Reviewed at: `2026-07-15T06:34:00Z` +Reviewed at: `2026-07-15T08:35:20Z` Reviewer run IDs: `auth06_final_senior`, `auth06_qa_review`, `auth06_security_review`, `auth06_product_ops_review`, `auth06_final_architecture`, `auth06_final_ci`, `auth06_final_docs`, @@ -26,6 +26,10 @@ Reviewer run IDs: `auth06_final_senior`, `auth06_qa_review`, links, and `git diff --check` passed. - All 71 integrated engineering-loop agent-gate tests passed, and the schema-v2 merge intent resolves the exact same-initiative AUTH-07 successor. +- GitHub Backend reached 983 passed tests and 83.11 percent repository coverage; + its only failure was the stale closed-world OpenAPI inventory. The inventory + now includes the two protected actor self-service routes, and all 27 API + control tests pass locally. - No workflow, dependency, coverage threshold, skip, exclusion, or package script changed. GitHub Backend remains authoritative for the repository-wide 78 percent floor; the multi-hour full suite was not repeated locally. @@ -44,6 +48,11 @@ Reviewer run IDs: `auth06_final_senior`, `auth06_qa_review`, | reuse/dedup | PASS | none | Shared rate-control and actor failure mapping replace duplicate route behavior. | | test delta | PASS | none | New assertions exercise meaningful behavior without skips, xfails, or weakened expectations. | +The final senior/architecture/reuse, QA/test-delta/CI, and +security/product/docs repair reviews independently verified the 48-route and +46-protected-route fingerprints. Removing only `GET` and +`PATCH /api/v1/actors/me` reproduces both prior inventories exactly. + ## Findings Resolved Review repair closed stale eligibility on assigned task start, operator-override @@ -60,5 +69,5 @@ Open sub-agent sessions: none ## Remaining Gate -GitHub Backend, Agent Gates, CodeRabbit, and explicit human merge approval +GitHub Backend rerun, Agent Gates, CodeRabbit, and explicit human merge approval remain pending. `WS-AUTH-001-07` must not start automatically. diff --git a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md index 675c82bd..590e8d7d 100644 --- a/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md +++ b/.agent-loop/initiatives/WS-AUTH-001-workstream-authorization-service/reviews/WS-AUTH-001-06-pr-trust-bundle.md @@ -101,6 +101,10 @@ authorization remains unchanged until the grant and evaluator cutovers. - Focused actor/auth/task/project/checker/rate-control behavior and migration suites passed; final coverage data includes the repaired static inventory and added repeated-access, unavailable-control, and compatibility assertions. +- GitHub Backend reached 983 passed tests and 83.11 percent repository coverage. + Its only failure was the OpenAPI inventory still expecting the pre-AUTH-06 + route set; all 27 API-control tests pass with the two protected actor routes + represented in the strict counts and hashes. - Ruff, stale wording, stale authorization docs, changed Markdown links, diff integrity, all 71 engineering-loop agent-gate tests, loop-memory state, and the schema-v2 AUTH-06 merge-intent validator passed. @@ -122,7 +126,8 @@ still enforce the repository-wide 78 percent baseline. ## Reviewer Results -Exact reviewed SHA `abd76c995e51645b61d4d3ac07f1ff82ab6eb740` passed senior +Exact reviewed code SHA `25d9455f6dca41b207a0ba3aaba8de9cc2683a17` +passed senior engineering, QA/test, security/auth, product/ops, architecture, CI integrity, docs, reuse/dedup, and test-delta review with no blocking findings. @@ -132,7 +137,7 @@ CodeRabbit's seven inline findings and three nitpicks were triaged. All valid runtime, migration, lifecycle, privacy, test-harness, and test-contract findings were repaired. Its generic diff-local docstring percentage is not the repository's configured gate and required no narration-only churn. GitHub checks, -the next CodeRabbit head, and human review remain pending. +the next CodeRabbit head, the Backend rerun, and human review remain pending. ## Remaining Risks