From 3663d50768a8ae141951c10a74fed881d60238ae Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Mon, 17 Aug 2026 03:05:41 -0400 Subject: [PATCH 1/2] Harden authorization authority boundaries --- .github/github.json | 1 + .github/workflows/authz-policy-reconcile.yml | 12 + .github/workflows/deploy-launchplane.yml | 89 +---- .../generic-web-preview-authorization.yml | 48 ++- .../reusable-authz-policy-reconcile.yml | 28 +- ...usable-generic-web-preview-authz-apply.yml | 26 ++ AGENTS.md | 4 + control_plane/authz_grant_service.py | 193 ++++++++-- control_plane/http_app.py | 7 + control_plane/service_auth.py | 16 +- control_plane/service_human_auth.py | 46 ++- docs/README.md | 2 + docs/authorization-authority.md | 105 ++++++ docs/config-boundary.md | 7 +- docs/operations.md | 65 ++-- docs/records.md | 8 +- docs/service-boundary.md | 9 +- tests/test_authz_grant_service.py | 347 +++++++++++++++++- tests/test_authz_operator_workflow.py | 107 +++--- tests/test_generic_web_preview_authz.py | 23 ++ tests/test_product_onboarding.py | 14 +- tests/test_service.py | 22 +- tests/test_service_auth.py | 57 +++ tests/test_service_bootstrap.py | 7 + tests/test_service_human_auth.py | 57 ++- 25 files changed, 1056 insertions(+), 244 deletions(-) create mode 100644 docs/authorization-authority.md diff --git a/.github/github.json b/.github/github.json index 23d878b8a..69c4e6343 100644 --- a/.github/github.json +++ b/.github/github.json @@ -9,6 +9,7 @@ "index": "docs/README.md", "architecture": "docs/architecture.md", "configBoundary": "docs/config-boundary.md", + "authorizationAuthority": "docs/authorization-authority.md", "serviceBoundary": "docs/service-boundary.md", "dokployServiceDeployments": "docs/dokploy-service-deployments.md", "driverDescriptors": "docs/driver-descriptors.md", diff --git a/.github/workflows/authz-policy-reconcile.yml b/.github/workflows/authz-policy-reconcile.yml index e5c42f597..bb1dad4bb 100644 --- a/.github/workflows/authz-policy-reconcile.yml +++ b/.github/workflows/authz-policy-reconcile.yml @@ -63,6 +63,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.primary reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} @@ -146,6 +147,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.product-health-monitoring reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} @@ -205,6 +207,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.odoo-route-binding reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} @@ -216,6 +219,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.odoo-external-route-binding reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} @@ -227,6 +231,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.odoo-testing-ingress-route reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} @@ -238,6 +243,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.odoo-testing-route-binding-refresh reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} @@ -249,6 +255,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.odoo-testing-target-replacement reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} @@ -260,6 +267,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.odoo-opw-preview-feedback reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} @@ -271,6 +279,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.odoo-opw-production-enrollment reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} @@ -282,6 +291,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.odoo-production-enrollment reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} @@ -293,6 +303,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.odoo-production-operation-read reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} @@ -304,6 +315,7 @@ jobs: uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@39aecc250d6dee91204e24673725bd1ea1ca6bda # main with: mode: ${{ inputs.mode }} + expected_managed_set_id: operator.odoo-production-backup-restore reviewed_plan_sha256: ${{ inputs.reviewed_plan_sha256 }} reason: ${{ inputs.reason }} related_issue: ${{ inputs.related_issue }} diff --git a/.github/workflows/deploy-launchplane.yml b/.github/workflows/deploy-launchplane.yml index 1f6b7409f..08231b649 100644 --- a/.github/workflows/deploy-launchplane.yml +++ b/.github/workflows/deploy-launchplane.yml @@ -12,27 +12,6 @@ on: description: Exact immutable Launchplane image digest to deploy instead of building a new one. required: false type: string - authz_managed_mode: - description: Bootstrap review or apply of managed authorization without deploying Launchplane. - required: false - default: none - type: choice - options: - - none - - dry_run - - apply - authz_managed_reviewed_plan_sha256: - description: Managed plan SHA-256 returned by the reviewed dry run; required for apply. - required: false - type: string - authz_managed_reason: - description: Single-line operator reason bound into the managed review plan. - required: false - type: string - authz_managed_related_issue: - description: Single-line issue or change reference bound into the managed review plan. - required: false - type: string omit_every_code_env: description: Temporarily omit Every Code env for one compatibility deploy. required: false @@ -86,8 +65,7 @@ jobs: (github.event_name == 'workflow_dispatch' && github.ref_type == 'branch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && - inputs.break_glass_confirm == '' && - inputs.authz_managed_mode == 'none') + inputs.break_glass_confirm == '') runs-on: - self-hosted - ${{ vars.LAUNCHPLANE_RUNNER_LABEL }} @@ -1181,66 +1159,6 @@ jobs: launchplane-v2-deployed-smoke.json } >> "$GITHUB_STEP_SUMMARY" - operator-authz-managed-validate: - if: >- - github.event_name == 'workflow_dispatch' && - github.ref_type == 'branch' && - github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && - inputs.authz_managed_mode != 'none' - permissions: - contents: read - runs-on: ubuntu-latest - steps: - - name: Validate managed authz isolation - shell: bash - env: - BREAK_GLASS_CONFIRM: ${{ inputs.break_glass_confirm }} - BREAK_GLASS_IMAGE_REFERENCE: ${{ inputs.break_glass_image_reference }} - BREAK_GLASS_REASON: ${{ inputs.break_glass_reason }} - DEPLOY_IMAGE_REFERENCE: ${{ inputs.image_reference }} - OMIT_EVERY_CODE_ENV: ${{ inputs.omit_every_code_env }} - OMIT_NPMPLUS_ENV: ${{ inputs.omit_npmplus_env }} - OMIT_OWNER_AGENT_ENV: ${{ inputs.omit_owner_agent_env }} - OMIT_TERMINAL_AGENT_ENV: ${{ inputs.omit_terminal_agent_env }} - run: | - set -euo pipefail - if [ -n "$DEPLOY_IMAGE_REFERENCE" ]; then - echo "Managed authz reconciliation cannot include a deploy image input." >&2 - exit 1 - fi - if [ "$OMIT_EVERY_CODE_ENV" = "true" ] || - [ "$OMIT_TERMINAL_AGENT_ENV" = "true" ] || - [ "$OMIT_OWNER_AGENT_ENV" = "true" ] || - [ "$OMIT_NPMPLUS_ENV" = "true" ]; then - echo "Managed authz reconciliation cannot include deploy compatibility inputs." >&2 - exit 1 - fi - if [ -n "$BREAK_GLASS_CONFIRM" ] || - [ -n "$BREAK_GLASS_IMAGE_REFERENCE" ] || - [ -n "$BREAK_GLASS_REASON" ]; then - echo "Managed authz reconciliation cannot include break-glass rollback inputs." >&2 - exit 1 - fi - - operator-authz-managed: - needs: operator-authz-managed-validate - if: >- - github.event_name == 'workflow_dispatch' && - github.ref_type == 'branch' && - github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && - inputs.authz_managed_mode != 'none' - permissions: - contents: read - id-token: write - uses: cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@4dbef2945b0a297a6edaa949a42d8c7d4cbc01cd # main - with: - mode: ${{ inputs.authz_managed_mode }} - reviewed_plan_sha256: ${{ inputs.authz_managed_reviewed_plan_sha256 }} - reason: ${{ inputs.authz_managed_reason }} - related_issue: ${{ inputs.authz_managed_related_issue }} - secrets: - managed_set_json: ${{ secrets.LAUNCHPLANE_AUTHZ_MANAGED_SET_JSON }} - emergency-dokploy-rollback: if: >- github.event_name == 'workflow_dispatch' && @@ -1266,15 +1184,10 @@ jobs: - name: Validate manual break-glass request shell: bash env: - AUTHZ_MANAGED_MODE: ${{ inputs.authz_managed_mode }} BREAK_GLASS_IMAGE_REFERENCE: ${{ inputs.break_glass_image_reference }} BREAK_GLASS_REASON: ${{ inputs.break_glass_reason }} run: | set -euo pipefail - if [ "$AUTHZ_MANAGED_MODE" != "none" ]; then - echo "Break-glass rollback cannot be combined with managed authz reconciliation." >&2 - exit 1 - fi : "${LAUNCHPLANE_DOKPLOY_TARGET_TYPE:?Missing LAUNCHPLANE_DOKPLOY_TARGET_TYPE variable}" : "${LAUNCHPLANE_DOKPLOY_TARGET_ID:?Missing LAUNCHPLANE_DOKPLOY_TARGET_ID variable}" if [ -z "$BREAK_GLASS_IMAGE_REFERENCE" ]; then diff --git a/.github/workflows/generic-web-preview-authorization.yml b/.github/workflows/generic-web-preview-authorization.yml index 8e323db43..781cb505a 100644 --- a/.github/workflows/generic-web-preview-authorization.yml +++ b/.github/workflows/generic-web-preview-authorization.yml @@ -75,6 +75,8 @@ jobs: plan_sha256: ${{ steps.plan.outputs.plan_sha256 }} product: ${{ steps.inputs.outputs.product }} env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GITHUB_REF_NAME: ${{ github.ref }} LAUNCHPLANE_URL: ${{ vars.LAUNCHPLANE_PUBLIC_URL }} MODE: ${{ inputs.mode }} OPERATION: ${{ inputs.operation }} @@ -111,6 +113,11 @@ jobs: return value mode = required("MODE") + expected_ref = f"refs/heads/{os.environ['DEFAULT_BRANCH']}" + if os.environ["GITHUB_REF_NAME"] != expected_ref: + raise SystemExit( + "Generic-web preview authorization must run from the default branch." + ) if mode not in {"dry_run", "apply"}: raise SystemExit("mode must be dry_run or apply.") operation = required("OPERATION") @@ -240,6 +247,11 @@ jobs: exit 1 fi jq '.result.configuration' authz-plan.json > authz-reconcile-request.json + managed_set_id="$(jq -r '.managed_set_id // empty' authz-reconcile-request.json)" + if [ "$managed_set_id" != "operator.generic-web-preview" ]; then + echo "Preview authz plan returned an unexpected managed set." >&2 + exit 1 + fi echo "plan_sha256=$plan_sha256" >> "$GITHUB_OUTPUT" { echo "## Generic-web preview authorization plan" @@ -267,8 +279,42 @@ jobs: apply: if: ${{ inputs.mode == 'apply' }} needs: plan + concurrency: + group: launchplane-authz-policy + cancel-in-progress: false uses: cbusillo/launchplane/.github/workflows/reusable-generic-web-preview-authz-apply.yml@d21d609514404d3420fceb7fe53857ae407d3ff8 # main with: product: ${{ needs.plan.outputs.product }} plan_sha256: ${{ needs.plan.outputs.plan_sha256 }} - secrets: inherit + + verify-apply: + if: ${{ inputs.mode == 'apply' }} + needs: + - plan + - apply + runs-on: ubuntu-latest + steps: + - name: Download authorization apply evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.0 + with: + name: generic-web-preview-authz-apply + path: . + + - name: Verify applied authorization + shell: bash + env: + REVIEWED_PLAN_SHA256: ${{ needs.plan.outputs.plan_sha256 }} + run: | + set -euo pipefail + jq -e \ + --arg reviewed "$REVIEWED_PLAN_SHA256" \ + '.result.diff.managed_set_id == "operator.generic-web-preview" and + .result.diff.plan_sha256 == $reviewed and + (.result.changed | type == "boolean") and + (.result.diff.operational_readiness_blocked_rule_count | type == "number") and + ((.result.changed | not) or + .result.diff.operational_readiness_blocked_rule_count == 0)' \ + authz-apply.json >/dev/null || { + echo "Applied preview authorization did not match the reviewed contract." >&2 + exit 1 + } diff --git a/.github/workflows/reusable-authz-policy-reconcile.yml b/.github/workflows/reusable-authz-policy-reconcile.yml index b5b92c499..19eb7b5ca 100644 --- a/.github/workflows/reusable-authz-policy-reconcile.yml +++ b/.github/workflows/reusable-authz-policy-reconcile.yml @@ -9,9 +9,8 @@ name: Reusable Authz Policy Reconcile required: true type: string expected_managed_set_id: - description: Optional exact managed-set identity required from the protected configuration. - required: false - default: "" + description: Exact managed-set identity required from the protected configuration. + required: true type: string reviewed_plan_sha256: description: Plan SHA-256 returned by the reviewed dry run; required for apply. @@ -129,7 +128,9 @@ jobs: if re.fullmatch(r"[a-z0-9][a-z0-9._:/-]{0,127}", managed_set_id) is None: raise SystemExit("Managed authz configuration requires a stable managed_set_id.") expected_managed_set_id = os.environ.get("EXPECTED_MANAGED_SET_ID", "").strip() - if expected_managed_set_id and managed_set_id != expected_managed_set_id: + if not expected_managed_set_id: + raise SystemExit("Managed authz reconciliation requires expected_managed_set_id.") + if managed_set_id != expected_managed_set_id: raise SystemExit( "Managed authz configuration managed_set_id does not match the selected managed set." ) @@ -281,7 +282,9 @@ jobs: (.result.diff.plan_sha256 | test("^[0-9a-f]{64}$")) and (.result.diff.previous_policy_sha256 | test("^[0-9a-f]{64}$")) and (.result.diff.desired_policy_sha256 | test("^[0-9a-f]{64}$")) and - (.result.diff.desired_set_sha256 | test("^[0-9a-f]{64}$"))' \ + (.result.diff.desired_set_sha256 | test("^[0-9a-f]{64}$")) and + (.result.diff.operational_readiness_blocked_rule_count | type == "number") and + (.result.diff.operational_readiness_blockers | type == "array")' \ "$RESPONSE_FILE" >/dev/null || { echo "Launchplane managed authz response did not match the reviewed contract." >&2 exit 1 @@ -291,13 +294,20 @@ jobs: echo "Launchplane applied a different managed authz plan than the reviewed digest." >&2 exit 1 fi + changed="$(jq -r '.result.changed' "$RESPONSE_FILE")" + readiness_blockers="$( + jq -r '.result.diff.operational_readiness_blocked_rule_count' "$RESPONSE_FILE" + )" + if [ "$MODE" = "apply" ] && [ "$changed" = "true" ] && [ "$readiness_blockers" != "0" ]; then + echo "Launchplane refused to verify an authz apply with operational-readiness blockers." >&2 + exit 1 + fi jq '{ mode: .result.mode, changed: .result.changed, authz_policy: .result.authz_policy, diff: .result.diff }' "$RESPONSE_FILE" > "$EVIDENCE_DIRECTORY/summary.json" - changed="$(jq -r '.result.changed' "$RESPONSE_FILE")" schema_migrated="$(jq -r '.result.diff.schema_migrated' "$RESPONSE_FILE")" authorization_changed="$(jq -r '.result.diff.authorization_changed' "$RESPONSE_FILE")" previous_policy_sha256="$(jq -r '.result.diff.previous_policy_sha256' "$RESPONSE_FILE")" @@ -308,6 +318,10 @@ jobs: updated="$(jq -r '.result.diff.updated_rule_count' "$RESPONSE_FILE")" removed="$(jq -r '.result.diff.removed_rule_count' "$RESPONSE_FILE")" unchanged="$(jq -r '.result.diff.unchanged_rule_count' "$RESPONSE_FILE")" + readiness_reasons="$( + jq -c '[.result.diff.operational_readiness_blockers[]?.reason_codes[]?] | unique' \ + "$RESPONSE_FILE" + )" { echo "## Managed authorization reconciliation" echo @@ -317,6 +331,8 @@ jobs: echo "- Authorization changed: $authorization_changed" echo "- Schema migrated: $schema_migrated" echo "- Rules: added=$added adopted=$adopted updated=$updated removed=$removed unchanged=$unchanged" + echo "- Operational-readiness blockers: $readiness_blockers" + echo "- Operational-readiness reason codes: $readiness_reasons" printf '%s\n' "- Previous policy SHA-256: $previous_policy_sha256" printf '%s\n' "- Desired policy SHA-256: $desired_policy_sha256" printf '%s\n' "- Desired set SHA-256: $desired_set_sha256" diff --git a/.github/workflows/reusable-generic-web-preview-authz-apply.yml b/.github/workflows/reusable-generic-web-preview-authz-apply.yml index b81bfdbba..af44e39aa 100644 --- a/.github/workflows/reusable-generic-web-preview-authz-apply.yml +++ b/.github/workflows/reusable-generic-web-preview-authz-apply.yml @@ -17,6 +17,10 @@ permissions: contents: read id-token: write +concurrency: + group: launchplane-authz-policy + cancel-in-progress: false + jobs: apply: runs-on: ubuntu-latest @@ -47,6 +51,11 @@ jobs: echo "Reviewed authz configuration does not match its plan artifact." >&2 exit 1 fi + managed_set_id="$(jq -r '.managed_set_id // empty' authz-reconcile-request.json)" + if [ "$managed_set_id" != "operator.generic-web-preview" ]; then + echo "Reviewed preview authorization uses an unexpected managed set." >&2 + exit 1 + fi jq \ --arg reviewed "$artifact_plan_sha256" \ '.mode="apply" | .reviewed_plan_sha256=$reviewed' \ @@ -66,6 +75,23 @@ jobs: fail-result-paths: "" response-output-file: authz-apply.json + - name: Verify applied authorization + shell: bash + run: | + set -euo pipefail + jq -e \ + --arg reviewed "$PLAN_SHA256" \ + '.result.diff.managed_set_id == "operator.generic-web-preview" and + .result.diff.plan_sha256 == $reviewed and + (.result.changed | type == "boolean") and + (.result.diff.operational_readiness_blocked_rule_count | type == "number") and + ((.result.changed | not) or + .result.diff.operational_readiness_blocked_rule_count == 0)' \ + authz-apply.json >/dev/null || { + echo "Applied preview authorization did not match the reviewed contract." >&2 + exit 1 + } + - name: Summarize applied authorization if: always() shell: bash diff --git a/AGENTS.md b/AGENTS.md index d64701a2d..78ebc2461 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,10 @@ Treat this file as the launch checklist for each Every Code session in - Update docs in the same change when behavior or ownership changes. - Fix root causes, not symptoms; avoid workaround-only flows unless the operator explicitly asks for a time-boxed mitigation. +- Follow `docs/authorization-authority.md`. While issue `#2058` is unresolved, + do not propose, add, or apply new routine GitHub-secret/workflow-managed + authorization grants. Treat `authorization_denied` as an authority or + capability gap and block the affected work on the DB-native redesign. - Dispatch and watch protected GitHub operator workflows only through the installed `github_workflow_babysit.py` helper. Do not use raw `gh workflow run`, `gh run watch`, or a generic run waiter for those jobs; diff --git a/control_plane/authz_grant_service.py b/control_plane/authz_grant_service.py index 8f85cbfb8..d23cd8aec 100644 --- a/control_plane/authz_grant_service.py +++ b/control_plane/authz_grant_service.py @@ -169,6 +169,9 @@ def _normalize_desired_authz_policy(policy: LaunchplaneAuthzPolicy) -> Launchpla _IMMUTABLE_WORKFLOW_ACTION_SAFETIES = frozenset( {"prod", "destructive", "secret_backed", "policy_admin"} ) +_IMMUTABLE_GITHUB_HUMAN_ACTION_SAFETIES = frozenset( + {"prod", "destructive", "secret_backed", "policy_admin"} +) _MANAGED_AUTHZ_RECONCILE_SOURCE = "service:authz-managed-rule-set-reconcile" _AUTHZ_POLICY_ADMIN_ACTION = "authz_policy_grant.write" _OWNER_ACCEPTANCE_MANAGED_SET_ID = "operator.owner-acceptance" @@ -329,6 +332,36 @@ def _validate_reconcile(self) -> "AuthzManagedPolicyReconcileEnvelope": "Managed GitHub Actions authz rules require immutable repository_id " f"and repository_owner_id selectors ({rule.managed_rule_id})." ) + if isinstance(rule, GitHubHumanPolicyRule): + if not rule.roles: + raise ValueError( + "Managed GitHub human authz rules require at least one explicit role " + f"({rule.managed_rule_id})." + ) + if not any((rule.github_ids, rule.logins, rule.organizations, rule.teams)): + raise ValueError( + "Managed GitHub human authz rules require at least one principal " + f"selector ({rule.managed_rule_id})." + ) + if any( + _contains_selector_glob(selector) + for selector in (*rule.logins, *rule.organizations, *rule.teams) + ): + raise ValueError( + "Managed GitHub human authz rules require exact login, organization, " + f"and team selectors ({rule.managed_rule_id})." + ) + if ( + "admin" in rule.roles + or any( + action_safety(action) in _IMMUTABLE_GITHUB_HUMAN_ACTION_SAFETIES + for action in rule.actions + ) + ) and not rule.github_ids: + raise ValueError( + "Managed GitHub human admin and sensitive-action rules require " + f"immutable github_ids ({rule.managed_rule_id})." + ) return self @@ -529,6 +562,12 @@ class AuthzPolicyConflictError(ValueError): pass +class AuthzPolicySafetyError(AuthzPolicyConflictError): + def __init__(self, *, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + class AuthzPolicyRequestError(ValueError): pass @@ -918,25 +957,102 @@ def _authz_policy_without_managed_identities( ) +def _authz_rule_grants_policy_administration(rule: AuthzPolicyRule) -> bool: + if rule.actions and _AUTHZ_POLICY_ADMIN_ACTION not in rule.actions: + return False + if isinstance(rule, GitHubHumanPolicyRule): + product_allowed = not rule.products or "launchplane" in rule.products + context_allowed = not rule.contexts or "launchplane" in rule.contexts + else: + product_allowed = not rule.products or any( + fnmatchcase("launchplane", value) for value in rule.products + ) + context_allowed = not rule.contexts or any( + fnmatchcase("launchplane", value) for value in rule.contexts + ) + if not product_allowed or not context_allowed: + return False + if isinstance(rule, GitHubActionsPolicyRule): + return bool(rule.repository and rule.workflow_refs) + if isinstance(rule, GitHubHumanPolicyRule): + return bool(rule.github_ids and "admin" in rule.roles) + return bool(rule.subjects and rule.token_labels) + + +def _authz_policy_administrator_rules( + policy: LaunchplaneAuthzPolicy, +) -> tuple[AuthzPolicyRule, ...]: + return tuple( + rule + for _, rules in _authz_policy_rule_collections(policy) + for rule in rules + if _authz_rule_grants_policy_administration(rule) + ) + + def _authz_policy_retains_administration(policy: LaunchplaneAuthzPolicy) -> bool: - def grants_policy_administration(rule: object) -> bool: - actions = getattr(rule, "actions", ()) - products = getattr(rule, "products", ()) - contexts = getattr(rule, "contexts", ()) - return ( - (not actions or _AUTHZ_POLICY_ADMIN_ACTION in actions) - and (not products or "launchplane" in products) - and (not contexts or "launchplane" in contexts) + return bool(_authz_policy_administrator_rules(policy)) + + +def _authz_rule_allows_identity( + *, + rule: AuthzPolicyRule, + identity: LaunchplaneIdentity, + schema_version: Literal[1, 2], +) -> bool: + if isinstance(rule, GitHubActionsPolicyRule) and isinstance(identity, GitHubActionsIdentity): + return rule.allows( + identity=identity, + action=_AUTHZ_POLICY_ADMIN_ACTION, + product="launchplane", + context="launchplane", + schema_version=schema_version, ) + if isinstance(rule, GitHubHumanPolicyRule) and isinstance(identity, GitHubHumanIdentity): + return rule.allows( + identity=identity, + action=_AUTHZ_POLICY_ADMIN_ACTION, + product="launchplane", + context="launchplane", + schema_version=schema_version, + ) + if isinstance(rule, TerminalAgentPolicyRule) and isinstance(identity, TerminalAgentIdentity): + return rule.allows( + identity=identity, + action=_AUTHZ_POLICY_ADMIN_ACTION, + product="launchplane", + context="launchplane", + schema_version=schema_version, + ) + if isinstance(rule, LocalOperatorPolicyRule) and isinstance(identity, LocalOperatorIdentity): + return rule.allows( + identity=identity, + action=_AUTHZ_POLICY_ADMIN_ACTION, + product="launchplane", + context="launchplane", + schema_version=schema_version, + ) + if isinstance(rule, LocalAdminPolicyRule) and isinstance(identity, LocalAdminIdentity): + return rule.allows( + identity=identity, + action=_AUTHZ_POLICY_ADMIN_ACTION, + product="launchplane", + context="launchplane", + schema_version=schema_version, + ) + return False - return ( - any(grants_policy_administration(rule) for rule in policy.github_actions) - or any( - grants_policy_administration(rule) and (not rule.roles or "admin" in rule.roles) - for rule in policy.github_humans + +def _authz_policy_retains_independent_administration( + *, policy: LaunchplaneAuthzPolicy, applying_identity: LaunchplaneIdentity +) -> bool: + return any( + not _authz_rule_allows_identity( + rule=rule, + identity=applying_identity, + schema_version=policy.schema_version, ) - or any(grants_policy_administration(rule) for rule in policy.local_operators) - or any(grants_policy_administration(rule) for rule in policy.local_admins) + for rule in _authz_policy_administrator_rules(policy) ) @@ -1226,9 +1342,12 @@ def plan_managed_authz_policy_reconcile( if _authz_policy_retains_administration( base_policy ) and not _authz_policy_retains_administration(updated_policy): - raise AuthzPolicyConflictError( - "Managed authz policy reconciliation must retain at least one principal that can " - "administer Launchplane authz policy." + raise AuthzPolicySafetyError( + code="authz_policy_admin_unreachable", + message=( + "Managed authz policy reconciliation must retain at least one reachable principal " + "that can administer Launchplane authz policy." + ), ) desired_policy_sha256 = authz_policy_sha256(updated_policy) changed = current_record.policy_sha256 != desired_policy_sha256 @@ -1410,15 +1529,45 @@ def execute_managed_authz_policy_reconcile( current_record=current_record, expected_policy_sha256=authorized_policy_sha256, ) - if managed_diff.retired_unmanaged_compatibility_rule_count and not updated_policy.allows( + if managed_diff.changed and not updated_policy.allows( identity=identity, action=_AUTHZ_POLICY_ADMIN_ACTION, product=request.product, context="launchplane", ): - raise AuthzPolicyConflictError( - "Managed authz compatibility retirement must retain policy administration " - "authority for the applying identity." + raise AuthzPolicySafetyError( + code="authz_policy_applying_admin_removed", + message=( + "Managed authz policy reconciliation must retain policy administration " + "authority for the applying identity." + ), + ) + if ( + request.mode == "apply" + and managed_diff.changed + and managed_diff.operational_readiness_blocked_rule_count + ): + raise AuthzPolicySafetyError( + code="authz_operational_readiness_blocked", + message=( + "Managed authz policy reconciliation cannot apply while operational-readiness " + "blockers remain. Review the dry-run evidence and submit an exact candidate." + ), + ) + if ( + request.mode == "apply" + and managed_diff.changed + and not _authz_policy_retains_independent_administration( + policy=updated_policy, + applying_identity=identity, + ) + ): + raise AuthzPolicySafetyError( + code="authz_policy_independent_admin_unreachable", + message=( + "Managed authz policy reconciliation must retain a reachable policy " + "administrator independent from the applying identity." + ), ) diff = managed_diff.model_dump(mode="json") audit = authz_managed_policy_reconcile_audit_payload( diff --git a/control_plane/http_app.py b/control_plane/http_app.py index 726a49348..1f6e7fd93 100644 --- a/control_plane/http_app.py +++ b/control_plane/http_app.py @@ -14185,6 +14185,13 @@ async def apply_managed_authz_policy_route( code="invalid_request", message=str(error), ) from error + except control_plane_authz_grant_service.AuthzPolicySafetyError as error: + raise _launchplane_http_error( + status_code=409, + trace_id=trace_id, + code=error.code, + message=str(error), + ) from error except control_plane_authz_grant_service.AuthzPolicyConflictError as error: if authz_request.mode == "apply" and normalized_idempotency_key: replay_response = replay_stored_apply_idempotency( diff --git a/control_plane/service_auth.py b/control_plane/service_auth.py index 8d5978d1e..1023e8c23 100644 --- a/control_plane/service_auth.py +++ b/control_plane/service_auth.py @@ -461,15 +461,13 @@ def allows( target: AuthorizationTarget | None = None, schema_version: AuthzPolicySchemaVersion = 1, ) -> bool: - if self.github_ids and identity.github_id not in self.github_ids: - return False - if self.logins and not self._matches_any(identity.login, self.logins): - return False - if self.organizations and not self._intersects(identity.organizations, self.organizations): - return False - if self.teams and not self._intersects(identity.teams, self.teams): - return False - if self.roles and identity.role not in self.roles: + if not self.matches_principal( + github_id=identity.github_id, + login=identity.login, + organizations=identity.organizations, + teams=identity.teams, + role=identity.role, + ): return False if self.products and product not in self.products: return False diff --git a/control_plane/service_human_auth.py b/control_plane/service_human_auth.py index a01424ea0..538df7cc1 100644 --- a/control_plane/service_human_auth.py +++ b/control_plane/service_human_auth.py @@ -252,19 +252,20 @@ def fetch_identity( if isinstance(org, dict) and str(org.get("login", "")).strip() ) teams = frozenset(_team_names(team_payload)) - bootstrap_admin_email = next( - iter(sorted(self._config.bootstrap_admin_emails.intersection(email_candidates))), - "", + role = authz_policy.human_role_for( + github_id=github_id, + login=login, + organizations=organizations, + teams=teams, ) - if bootstrap_admin_email: - role: Literal["read_only", "admin"] | None = "admin" - else: - role = authz_policy.human_role_for( - github_id=github_id, - login=login, - organizations=organizations, - teams=teams, + bootstrap_admin_email = "" + if role is None and not authz_policy.github_humans: + bootstrap_admin_email = next( + iter(sorted(self._config.bootstrap_admin_emails.intersection(email_candidates))), + "", ) + if bootstrap_admin_email: + role = "admin" if role is None: raise PermissionError("GitHub user is not authorized for Launchplane.") return GitHubHumanIdentity( @@ -330,6 +331,18 @@ def _team_names(team_payload: object) -> tuple[str, ...]: return tuple(names) +def _has_db_backed_human_policy_administrator(policy: LaunchplaneAuthzPolicy) -> bool: + return any( + bool(rule.github_ids) + and not any((rule.logins, rule.organizations, rule.teams)) + and "admin" in rule.roles + and rule.products == ("launchplane",) + and rule.contexts == ("launchplane",) + and "authz_policy_grant.write" in rule.actions + for rule in policy.github_humans + ) + + class HumanSessionManager: def __init__( self, @@ -352,15 +365,18 @@ def authorized_role( identity: GitHubHumanIdentity, authz_policy: LaunchplaneAuthzPolicy, ) -> Literal["read_only", "admin"] | None: - email = identity.email.strip().lower() - if email and email in self._config.bootstrap_admin_emails: - return "admin" - return authz_policy.human_role_for( + role = authz_policy.human_role_for( github_id=identity.github_id, login=identity.login, organizations=identity.organizations, teams=identity.teams, ) + if role is not None or _has_db_backed_human_policy_administrator(authz_policy): + return role + email = identity.email.strip().lower() + if email and email in self._config.bootstrap_admin_emails: + return "admin" + return None def issue(self, identity: GitHubHumanIdentity) -> LaunchplaneHumanSession: now = self._now() diff --git a/docs/README.md b/docs/README.md index 3309349f0..fd648a7f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,8 @@ Use these docs as the source of truth for `launchplane`. - [architecture.md](architecture.md) — ownership boundaries and system shape. - [config-boundary.md](config-boundary.md) — bootstrap-vs-DB config authority and checked-in config authority limits. +- [authorization-authority.md](authorization-authority.md) — active authz + freeze, denial handling, transitional GitHub boundary, and DB-native target. - [service-boundary.md](service-boundary.md) — Launchplane HTTP ingress, GitHub OIDC trust, and API contracts. - [dokploy-service-deployments.md](dokploy-service-deployments.md) — contract diff --git a/docs/authorization-authority.md b/docs/authorization-authority.md new file mode 100644 index 000000000..451e7ccc9 --- /dev/null +++ b/docs/authorization-authority.md @@ -0,0 +1,105 @@ +--- +title: Authorization Authority +--- + +# Authorization Authority + +Launchplane's active PostgreSQL authorization-policy record is the live +application authority. GitHub identities, workflows, environments, repository +secrets, OIDC tokens, and checked-in files may authenticate callers or transport +reviewed requests, but they do not grant Launchplane permission by themselves. + +## Active Freeze + +Issue `#2058` freezes production authorization changes while the authentication +and authorization audit, DB-native administration design, migration plan, and +independent review remain incomplete. + +Until that work closes: + +- do not add new routine grants or managed sets through GitHub secrets or + workflows; +- do not create, edit, retarget, or dispatch a workflow merely to make an + `authorization_denied` operation succeed; +- route authorization gaps to `#2058` and add a native `blocked-by` relationship + from the affected work; +- permit only explicitly reviewed maintenance of an already-authorized + transitional path, or a documented bootstrap/break-glass recovery operation; +- do not apply a production policy change without the separate approval required + by the owning issue and operator boundary. + +This freeze does not prohibit code, tests, documentation, threat-model work, or +dry-run-only validation that cannot mutate live policy. + +## Current Transitional Model + +The current service has a DB-backed managed-rule reconciliation endpoint with +compare-and-swap, idempotency, reviewed-plan digests, and redacted evidence. A +protected GitHub workflow currently reads desired managed sets from repository +secrets and transports them to that endpoint through GitHub Actions OIDC. + +That workflow is transitional compatibility infrastructure. The database remains +the live decision authority, but GitHub-hosted desired sets still make GitHub +part of the effective administration chain. Do not interpret the workflow's +existence, its protected environment, or its listing in repository metadata as +approval to use GitHub for routine permission administration. + +## Denial Handling + +Treat `authorization_denied` as an authority result, not a credential-selection +hint. + +1. Record the denied action, scope, trace ID, and current work item. +2. Determine whether Launchplane already has a sanctioned native capability for + that record type. +3. If the capability exists but the caller lacks scope, block the work on the + authorization architecture and operator decision; do not borrow a workflow + identity. +4. If no native capability exists, treat it as an architecture gap and route it + to `#2058`/`#2061`; do not close the gap with a new workflow, secret, local + helper, direct database command, or provider call. + +Manual route probing, wildcard grants, temporary CI authority, and copied policy +payloads are not diagnostic substitutes. + +## Target Model + +The DB-native administration surface must support authenticated administrators +through Launchplane's API and UI: + +- inspect active policy, managed sets, principals, and effective access; +- explain denials without requiring policy-write permission; +- propose, dry-run, review, apply, revoke, and roll back exact changes; +- preserve the applying administrator and an independently recoverable + administrator; +- prevent final-admin lockout and reject known readiness blockers; +- retain immutable identity, least-privilege scope, revision, digest, + idempotency, and audit evidence; +- export and restore policy without making GitHub the durable desired-state + store. + +After parity and recovery gates pass, protected desired-set secrets and routine +authorization workflows must be retired. GitHub may remain an identity provider +and transport for already-authorized workloads, plus a narrowly bounded +bootstrap/break-glass path that cannot replace arbitrary policy. + +## Bootstrap And Break-Glass + +Bootstrap exists only to establish the first reachable DB-backed administrator +and service roots. It must stop acting as ordinary runtime authority after +cutover. The current service disables bootstrap-email role elevation only after +the active policy contains an immutable-ID-bound human administrator with exact +Launchplane policy-administration scope; explicit DB denial then wins on login +and session revalidation. Legacy human rules retain their existing runtime +matching semantics until a reviewed migration replaces wildcard or implicit +selectors, while all newly reconciled managed human rules require explicit +roles, explicit principals, exact selectors, and immutable IDs for sensitive +access. Changed applies must also retain a policy administrator independent from +the applying identity. Break-glass must use a separate credential and approval +boundary, bind +the expected active policy digest, make the smallest recoverable change, append +audit evidence, and require normalization through the ordinary DB-native path. + +Immutable-image rollback is service-code recovery; it is not authorization-data +recovery. Do not claim that rolling back the Launchplane image repairs an active +DB policy. diff --git a/docs/config-boundary.md b/docs/config-boundary.md index 1075308be..79d4cfba8 100644 --- a/docs/config-boundary.md +++ b/docs/config-boundary.md @@ -303,5 +303,8 @@ Product retirement does not add checked-in product or provider authority. The workflow accepts operator-supplied product, instance, target digest, reason, issue, reviewed plan, and idempotency values; the service resolves all real context, provider target, runtime, and secret authority from DB-backed records. -The authorization managed-set secret routes policy material through the -existing protected authz reconciliation workflow and is not a product catalog. +Authorization managed-set secrets are transitional desired input routed through +the protected reconciliation workflow. They are not live policy authority and +must not become a product catalog or the routine durable permissions editor. +While the `#2058` authorization audit is open, do not add new routine managed +sets or grants through those secrets; follow `authorization-authority.md`. diff --git a/docs/operations.md b/docs/operations.md index 6b81bb7ea..82702a75d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -552,7 +552,9 @@ requires DB-backed policy storage, enforces `authz_policy_grant.write`, preserves signed-in GitHub human-session callers, and keeps dry-runs stateless. Apply atomically commits the active-policy compare-and-swap and completed `Idempotency-Key` replay evidence. Managed reconciliation is the sole policy -write contract for every principal type. +write implementation currently available for every principal type. That is an +implementation fact, not approval for GitHub to remain durable desired-policy +authority. Follow `authorization-authority.md` and the active `#2058` freeze. `POST /v1/authz-diagnostics/github-actions/evaluate` is a read-only diagnostic route for GitHub Actions callers. It evaluates only the calling OIDC identity @@ -562,10 +564,12 @@ active policy record/revision plus opaque identity and rule fingerprints and failed selector categories; it never returns workflow refs, repositories, policy values, or other principal rules. Ordinary route denials remain generic. -Operators mutate shared or production authz through the deployed service, not -through direct DB commands or a local CLI from an arbitrary checkout. Store the -complete desired rules for one `managed_set_id` in a protected repository -secret. `LAUNCHPLANE_AUTHZ_MANAGED_SET_JSON` owns the primary operator set; +Operators never mutate shared or production authz through direct DB commands or +a local CLI from an arbitrary checkout. The protected secret/workflow model +below is transitional compatibility for already-existing managed sets, not the +routine path for new grants. Do not create a managed set or modify production +authorization merely to resolve another task's `authorization_denied` result. +`LAUNCHPLANE_AUTHZ_MANAGED_SET_JSON` currently carries the primary operator set; `LAUNCHPLANE_AUTHZ_POLICY_RECONCILE_MANAGED_SET_JSON` owns the exact immutable policy-admin worker rules for the standalone authz wrapper and must declare the `operator.authz-policy-reconcile` managed-set identity; @@ -658,19 +662,18 @@ deliberately excluded from that secret. The policy-admin worker set is self-rotating. Before repinning the standalone wrapper, add a distinct exact rule for the next reusable-worker SHA through the currently authorized worker, apply the reviewed expansion, then advance the -wrapper. A first-time recovery may temporarily expose -`authz_managed_set = authz-policy-reconcile` in the deploy workflow's existing -authz-only path so the previous immutable worker can add the standalone rule. -That selector is a bootstrap bridge only: verify the new wrapper, then remove -the selector in the next change. It must never replace or partially reconstruct -`LAUNCHPLANE_AUTHZ_MANAGED_SET_JSON`. - -The reusable authz worker accepts an optional exact expected managed-set -identity from a reviewed wrapper. When provided, the worker rejects protected +wrapper. The former temporary deploy selector for first-time policy-admin +recovery no longer exists. Do not reintroduce it or represent image rollback as +DB-policy recovery. A missing authorized administrator is a `#2058` architecture +blocker until a bounded, independently protected DB-policy recovery contract is +designed and reviewed. + +The reusable authz worker requires an exact expected managed-set identity from +every reviewed wrapper. The worker rejects protected configuration whose `managed_set_id` does not match before rendering or sending -the reconciliation request. Land this compatibility input before advancing a -pinned wrapper that depends on it; do not pass a new input to an older immutable -worker revision. +the reconciliation request. Every standalone selector binds one exact +`operator.*` identity. The deploy workflow is not an authorization wrapper and +does not carry a managed-set secret. ### Reusable workflow SHA rollout for readiness-gated actions @@ -681,7 +684,8 @@ worker without creating an authorization gap by using two separate exact rules: 1. Add a new rule with a distinct `managed_rule_id` and the new immutable worker SHA while retaining the old exact rule unchanged. Each rule must keep a singleton `job_workflow_refs` selector and the same exact lane/action scope. -2. Run `Manage Launchplane Authorization` in `dry_run` mode and require +2. Only for an explicitly approved maintenance of this already-authorized + transitional set, run `Manage Launchplane Authorization` in `dry_run` mode and require `operational_readiness_blocked_rule_count=0` in the recorded diff before approving the protected `launchplane-authz-admin` job. Apply the reviewed expansion, then deploy the wrapper that calls the new worker SHA. @@ -691,12 +695,14 @@ worker without creating an authorization gap by using two separate exact rules: the readiness preflight. Do not place old and new worker SHAs together in one readiness-sensitive rule. -That overlap can be valid transitional authorization, but it is intentionally -not final operational-readiness shape and will report +An existing overlap can remain valid transitional authorization, but it is +intentionally not final operational-readiness shape and will report `job_workflow_refs_not_singleton`. The managed-authz dry-run records bounded rule IDs, affected readiness actions, and reason codes without exposing selector -values. Every expansion and contraction still requires the protected human -approval; diagnostics do not bypass or weaken that control. +values. Any changed apply is rejected until the desired set uses separate exact +rules; an unchanged replay remains a no-op. Every expansion and contraction +still requires the protected human approval; diagnostics do not bypass or +weaken that control. `Tracked Target Logs` and `Odoo Website Bootstrap Override` follow this same two-change rollout. Their dispatch files are thin operator entrypoints pinned to @@ -734,15 +740,14 @@ read with all mutation flags disabled so drift fails closed instead of changing the route. Context-scoped callers retain the existing provider-mutation contract; do not grant that broader scope merely to inspect one testing lane. -Use the `Manage Launchplane Authorization` workflow on the default branch. -Select the intended `managed_set`, then dispatch `mode=dry_run` with the final -single-line reason and related issue that will also be used for apply. The -environment gate protects the OIDC-minting job, +For explicitly approved maintenance of an already-authorized transitional set, +use `Manage Launchplane Authorization` on the default branch. Select the +intended `managed_set`, then dispatch `mode=dry_run` with the final single-line +reason and related issue that will also be used for apply. The environment gate +protects the OIDC-minting job, the worker is pinned to a reviewed immutable revision, and all authz runs share -one non-canceling concurrency group. During the one-time bootstrap before the -dedicated wrapper is present in active policy, dispatch `Deploy Launchplane` -with `authz_managed_mode=dry_run`; that job calls the same protected immutable -worker without building or deploying an image. +one non-canceling concurrency group. `Deploy Launchplane` has no authorization +inputs or jobs and must not be used for policy bootstrap or recovery. Babysit protected operator workflows with the installed GitHub workflow helper instead of dispatching and then polling by workflow name: diff --git a/docs/records.md b/docs/records.md index f561405f0..38ff78db9 100644 --- a/docs/records.md +++ b/docs/records.md @@ -493,9 +493,11 @@ an ORM column/table or remains only in the evidence payload. actions, and selector-shape reason codes, but never repository IDs, workflow refs, products, contexts, or instances. A rule containing old and new worker SHAs in one `job_workflow_refs` selector may remain valid transitional - authorization, but it reports `job_workflow_refs_not_singleton`. Readiness-safe - expansion uses two separately identified exact rules, one immutable worker SHA - per rule, followed by reviewed contraction of the old rule. + authorization, but it reports `job_workflow_refs_not_singleton` and blocks any + changed apply for that managed set. An unchanged apply remains a no-op. + Readiness-safe expansion uses two separately identified exact rules, one + immutable worker SHA per rule, followed by reviewed contraction of the old + rule. Generic-web preview retirement plan evidence additionally records bounded authority sources, target managed rule IDs/count, and a SHA-256 digest of the resolved repository identity. It never records or returns raw numeric diff --git a/docs/service-boundary.md b/docs/service-boundary.md index 2d3733678..79039044b 100644 --- a/docs/service-boundary.md +++ b/docs/service-boundary.md @@ -729,11 +729,10 @@ stable managed rule after canary evidence. The standalone policy-admin worker owns its exact immutable grants in the separate `operator.authz-policy-reconcile` managed set. Rotate that authority by expanding through the currently authorized worker, advancing the wrapper pin, -verifying the new identity, and then contracting the old rule. When the set is -first established, the deploy workflow may carry a temporary authz-only -selector that forwards the dedicated protected secret to the previous worker. -That bridge does not deploy Launchplane, cannot accept deploy or rollback -inputs, and is removed after the standalone wrapper succeeds. +verifying the new identity, and then contracting the old rule. The deploy +workflow no longer carries any authorization inputs, secret, or reconciliation +job. Do not reintroduce a deploy-time policy bridge; missing policy-admin +authority is a DB-native recovery design blocker. Production diagnostic and repair workers do not accept a service URL or OIDC audience from their callers. Thin dispatch workflows may forward only the typed diff --git a/tests/test_authz_grant_service.py b/tests/test_authz_grant_service.py index 6d4cdcdcd..99e50c1dd 100644 --- a/tests/test_authz_grant_service.py +++ b/tests/test_authz_grant_service.py @@ -12,6 +12,7 @@ AuthzManagedPolicyReconcileEnvelope, AuthzPolicyConflictError, AuthzPolicyRequestError, + AuthzPolicySafetyError, execute_managed_authz_policy_reconcile, plan_managed_authz_policy_reconcile, summarize_active_authz_policy_record, @@ -25,7 +26,9 @@ GitHubActionsIdentity, GitHubActionsPolicyRule, LaunchplaneAuthzPolicy, + LocalAdminPolicyRule, LocalOperatorPolicyRule, + TerminalAgentPolicyRule, ) @@ -114,6 +117,34 @@ def _authz_rollout_fixture(filename: str) -> LaunchplaneAuthzPolicy: ) +def _workflow_admin_identity() -> GitHubActionsIdentity: + return replace( + _identity(), + job_workflow_ref=( + "cbusillo/launchplane/.github/workflows/reusable-manage-authorization.yml@" + "a" * 40 + ), + ) + + +def _workflow_admin_rule( + identity: GitHubActionsIdentity, + *, + managed_rule_id: str = "authz.admin", +) -> GitHubActionsPolicyRule: + return GitHubActionsPolicyRule( + managed_set_id="operator.launchplane", + managed_rule_id=managed_rule_id, + repository=identity.repository, + repository_id=identity.repository_id, + repository_owner_id=identity.repository_owner_id, + workflow_refs=(identity.workflow_ref,), + job_workflow_refs=(identity.job_workflow_ref,), + products=("launchplane",), + contexts=("launchplane",), + actions=("authz_policy_grant.write",), + ) + + class AuthzManagedPolicyServiceTests(unittest.TestCase): def test_health_monitoring_managed_rule_requires_pinned_reusable_workflow( self, @@ -506,11 +537,116 @@ def test_managed_reconcile_rejects_removing_last_policy_administrator(self) -> N } ) - with self.assertRaisesRegex(AuthzPolicyConflictError, "retain at least one principal"): + with self.assertRaises(AuthzPolicySafetyError) as raised: plan_managed_authz_policy_reconcile( record_store=_AuthzPolicyStore((_active_record_for_policy(current_policy),)), request=request, ) + self.assertEqual(raised.exception.code, "authz_policy_admin_unreachable") + self.assertIn("retain at least one reachable principal", str(raised.exception)) + + def test_managed_reconcile_counts_terminal_agent_policy_administrator(self) -> None: + terminal_admin = TerminalAgentPolicyRule( + managed_set_id="operator.terminal-admin", + managed_rule_id="terminal.admin", + subjects=("terminal-agent",), + token_labels=("terminal-admin",), + products=("launchplane",), + contexts=("launchplane",), + actions=("authz_policy_grant.write",), + ) + current_record = _active_record_for_policy( + LaunchplaneAuthzPolicy(schema_version=2, terminal_agents=(terminal_admin,)) + ) + request = AuthzManagedPolicyReconcileEnvelope( + schema_version=2, + product="launchplane", + managed_set_id="operator.terminal-admin", + desired_policy=LaunchplaneAuthzPolicy(schema_version=2), + ) + + with self.assertRaises(AuthzPolicySafetyError) as raised: + plan_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((current_record,)), + request=request, + ) + + self.assertEqual(raised.exception.code, "authz_policy_admin_unreachable") + + def test_managed_apply_requires_independent_policy_administrator(self) -> None: + identity = _workflow_admin_identity() + applying_admin = _workflow_admin_rule(identity) + retired_rule = applying_admin.model_copy( + update={ + "managed_rule_id": "service.read", + "actions": ("product_environment.read",), + } + ) + + def apply_request( + current_record: LaunchplaneAuthzPolicyRecord, + ) -> AuthzManagedPolicyReconcileEnvelope: + dry_run = AuthzManagedPolicyReconcileEnvelope( + schema_version=2, + product="launchplane", + managed_set_id="operator.launchplane", + reason="Retire the obsolete managed read rule.", + desired_policy=LaunchplaneAuthzPolicy( + schema_version=2, + github_actions=(applying_admin,), + ), + ) + _, _, _, diff = plan_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((current_record,)), + request=dry_run, + ) + return AuthzManagedPolicyReconcileEnvelope.model_validate( + { + **dry_run.model_dump(mode="json"), + "mode": "apply", + "reviewed_plan_sha256": diff.plan_sha256, + } + ) + + current_policy = LaunchplaneAuthzPolicy( + schema_version=2, + github_actions=(applying_admin, retired_rule), + ) + current_record = _active_record_for_policy(current_policy) + with self.assertRaises(AuthzPolicySafetyError) as raised: + execute_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((current_record,)), + request=apply_request(current_record), + identity=identity, + trace_id="trace-independent-admin-required", + now_timestamp=lambda: "2026-08-17T00:00:00Z", + authorized_policy_sha256=current_record.policy_sha256, + ) + self.assertEqual( + raised.exception.code, + "authz_policy_independent_admin_unreachable", + ) + + independent_admin = LocalAdminPolicyRule( + subjects=("recovery-admin",), + token_labels=("recovery-admin",), + products=("launchplane",), + contexts=("launchplane",), + actions=("authz_policy_grant.write",), + ) + recoverable_record = _active_record_for_policy( + current_policy.model_copy(update={"local_admins": (independent_admin,)}) + ) + result = execute_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((recoverable_record,)), + request=apply_request(recoverable_record), + identity=identity, + trace_id="trace-independent-admin-retained", + now_timestamp=lambda: "2026-08-17T00:00:00Z", + authorized_policy_sha256=recoverable_record.policy_sha256, + ) + + self.assertTrue(result.changed) def test_managed_reconcile_rejects_unreviewed_apply_plan(self) -> None: current_record = _active_record() @@ -561,6 +697,70 @@ def test_managed_reconcile_requires_managed_desired_rules(self) -> None: } ) + def test_managed_reconcile_requires_scoped_github_human_principals(self) -> None: + base_rule = { + "managed_set_id": "operator.humans", + "managed_rule_id": "human.read", + "products": ["launchplane"], + "contexts": ["launchplane"], + "actions": ["product_profile.read"], + } + invalid_rules = ( + ({**base_rule, "roles": ["read_only"]}, "at least one principal selector"), + ({**base_rule, "github_ids": [1001]}, "at least one explicit role"), + ( + {**base_rule, "logins": ["*"], "roles": ["read_only"]}, + "require exact login, organization, and team selectors", + ), + ( + {**base_rule, "logins": ["owner"], "roles": ["admin"]}, + "require immutable github_ids", + ), + ( + { + **base_rule, + "logins": ["owner"], + "roles": ["read_only"], + "actions": ["authz_policy_grant.write"], + }, + "require immutable github_ids", + ), + ) + + for invalid_rule, message in invalid_rules: + with self.subTest(message=message), self.assertRaisesRegex(ValidationError, message): + AuthzManagedPolicyReconcileEnvelope.model_validate( + { + "schema_version": 2, + "product": "launchplane", + "managed_set_id": "operator.humans", + "desired_policy": { + "schema_version": 2, + "github_humans": [invalid_rule], + }, + } + ) + + request = AuthzManagedPolicyReconcileEnvelope.model_validate( + { + "schema_version": 2, + "product": "launchplane", + "managed_set_id": "operator.humans", + "desired_policy": { + "schema_version": 2, + "github_humans": [ + { + **base_rule, + "github_ids": [1001], + "roles": ["admin"], + } + ], + }, + } + ) + + self.assertEqual(request.desired_policy.github_humans[0].github_ids, (1001,)) + def test_owner_acceptance_managed_set_enforces_minimum_human_boundary(self) -> None: valid_rule = { "managed_set_id": "operator.owner-acceptance", @@ -1068,10 +1268,7 @@ def reconcile_request( github_actions=(compatibility_rule, stale_managed_rule), ) ) - with self.assertRaisesRegex( - AuthzPolicyConflictError, - "retain policy administration authority for the applying identity", - ): + with self.assertRaises(AuthzPolicySafetyError): execute_managed_authz_policy_reconcile( record_store=_AuthzPolicyStore((stale_record,)), request=reconcile_request(stale_managed_rule), @@ -1100,6 +1297,57 @@ def reconcile_request( result_diff = cast(dict[str, object], result.driver_result["diff"]) self.assertEqual(result_diff["retired_unmanaged_compatibility_rule_count"], 1) + def test_managed_route_requires_every_change_to_retain_applying_admin(self) -> None: + identity = replace( + _identity(), + job_workflow_ref=( + "cbusillo/launchplane/.github/workflows/reusable-manage-authorization.yml@" + + "a" * 40 + ), + ) + current_rule = GitHubActionsPolicyRule( + managed_set_id="operator.launchplane", + managed_rule_id="authz.admin", + repository=identity.repository, + repository_id=identity.repository_id, + repository_owner_id=identity.repository_owner_id, + workflow_refs=(identity.workflow_ref,), + job_workflow_refs=(identity.job_workflow_ref,), + products=("launchplane",), + contexts=("launchplane",), + actions=("authz_policy_grant.write",), + ) + replacement_rule = current_rule.model_copy(update={"repository_id": "9999"}) + current_record = _active_record_for_policy( + LaunchplaneAuthzPolicy(schema_version=2, github_actions=(current_rule,)) + ) + request = AuthzManagedPolicyReconcileEnvelope.model_validate( + { + "schema_version": 2, + "product": "launchplane", + "managed_set_id": "operator.launchplane", + "desired_policy": { + "schema_version": 2, + "github_actions": [replacement_rule.model_dump(mode="json")], + }, + } + ) + + with self.assertRaises(AuthzPolicySafetyError) as raised: + execute_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((current_record,)), + request=request, + identity=identity, + trace_id="trace-managed-lockout", + now_timestamp=lambda: "2026-08-17T00:00:00Z", + authorized_policy_sha256=current_record.policy_sha256, + ) + self.assertEqual(raised.exception.code, "authz_policy_applying_admin_removed") + self.assertIn( + "retain policy administration authority for the applying identity", + str(raised.exception), + ) + def test_managed_reconcile_rejects_ambiguous_compatibility_retirement(self) -> None: managed_rule = GitHubActionsPolicyRule( managed_set_id="operator.launchplane", @@ -1441,6 +1689,95 @@ def test_managed_reconcile_reports_single_rule_worker_overlap_as_not_readiness_f ("job_workflow_refs_not_singleton",), ) + def test_managed_reconcile_rejects_apply_with_operational_readiness_blockers( + self, + ) -> None: + identity = _workflow_admin_identity() + admin_rule = _workflow_admin_rule(identity) + current_record = _active_record_for_policy( + LaunchplaneAuthzPolicy(schema_version=2, github_actions=(admin_rule,)) + ) + dry_run = AuthzManagedPolicyReconcileEnvelope( + schema_version=2, + product="launchplane", + managed_set_id="test.operational-readiness-rollout", + reason="Verify readiness blockers fail closed.", + desired_policy=_authz_rollout_fixture("operational-readiness-overlap.json"), + ) + _, _, _, diff = plan_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((current_record,)), + request=dry_run, + ) + apply_request = AuthzManagedPolicyReconcileEnvelope.model_validate( + { + **dry_run.model_dump(mode="json"), + "mode": "apply", + "reason": "Verify readiness blockers fail closed.", + "reviewed_plan_sha256": diff.plan_sha256, + } + ) + + with self.assertRaises(AuthzPolicySafetyError) as raised: + execute_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((current_record,)), + request=apply_request, + identity=identity, + trace_id="trace-readiness-blocked", + now_timestamp=lambda: "2026-08-17T00:00:00Z", + authorized_policy_sha256=current_record.policy_sha256, + ) + self.assertEqual(raised.exception.code, "authz_operational_readiness_blocked") + self.assertIn( + "cannot apply while operational-readiness blockers remain", + str(raised.exception), + ) + + def test_managed_reconcile_allows_noop_apply_with_existing_readiness_blockers( + self, + ) -> None: + identity = _workflow_admin_identity() + admin_rule = _workflow_admin_rule(identity) + blocked_policy = _authz_rollout_fixture("operational-readiness-overlap.json") + current_policy = LaunchplaneAuthzPolicy( + schema_version=2, + github_actions=(admin_rule, *blocked_policy.github_actions), + ) + current_record = _active_record_for_policy(current_policy) + dry_run = AuthzManagedPolicyReconcileEnvelope( + schema_version=2, + product="launchplane", + managed_set_id="test.operational-readiness-rollout", + reason="Verify no-op applies remain idempotent.", + desired_policy=blocked_policy, + ) + _, _, _, diff = plan_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((current_record,)), + request=dry_run, + ) + apply_request = AuthzManagedPolicyReconcileEnvelope.model_validate( + { + **dry_run.model_dump(mode="json"), + "mode": "apply", + "reviewed_plan_sha256": diff.plan_sha256, + } + ) + + result = execute_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((current_record,)), + request=apply_request, + identity=identity, + trace_id="trace-readiness-noop", + now_timestamp=lambda: "2026-08-17T00:00:00Z", + authorized_policy_sha256=current_record.policy_sha256, + ) + + self.assertFalse(result.changed) + result_diff = cast(dict[str, object], result.driver_result["diff"]) + self.assertEqual( + result_diff["operational_readiness_blocked_rule_count"], + 1, + ) + def test_managed_reconcile_accepts_split_exact_worker_rollout_as_readiness_final( self, ) -> None: diff --git a/tests/test_authz_operator_workflow.py b/tests/test_authz_operator_workflow.py index 190b4b576..51d05f358 100644 --- a/tests/test_authz_operator_workflow.py +++ b/tests/test_authz_operator_workflow.py @@ -144,6 +144,16 @@ def test_dispatch_wrapper_calls_one_immutable_worker(self) -> None: "${{ secrets.LAUNCHPLANE_AUTHZ_ODOO_PRODUCTION_BACKUP_RESTORE_MANAGED_SET_JSON }}", ), } + raw_managed_set_options = managed_set_input["options"] + assert isinstance(raw_managed_set_options, list) + managed_set_options: list[str] = [] + for option in raw_managed_set_options: + assert isinstance(option, str) + managed_set_options.append(option) + self.assertEqual( + set(managed_set_options), + {job_name.removeprefix("reconcile-") for job_name in expected_jobs}, + ) self.assertEqual(set(self.dispatch_workflow.jobs), set(expected_jobs)) for job_name, (condition, expected_secret) in expected_jobs.items(): with self.subTest(job=job_name): @@ -162,39 +172,52 @@ def test_dispatch_wrapper_calls_one_immutable_worker(self) -> None: dispatch_inputs = dispatch_job["with"] assert isinstance(dispatch_inputs, dict) expected_managed_set_ids = { + "reconcile-primary": "operator.primary", "reconcile-authz-policy-reconcile": "operator.authz-policy-reconcile", "reconcile-generic-web-onboarding": "operator.generic-web-onboarding", "reconcile-manager-preview-approval": "operator.manager-preview-approval", "reconcile-owner-acceptance": "operator.owner-acceptance", "reconcile-product-owner-policy-admin": "operator.product-owner-policy-admin", + "reconcile-product-health-monitoring": "operator.product-health-monitoring", "reconcile-product-retirement": "operator.product-retirement", - "reconcile-detached-application-retirement": ( - "operator.detached-application-retirement" + "reconcile-detached-application-retirement": "operator.detached-application-retirement", + "reconcile-preview-feedback-remediation": "operator.preview-feedback-remediation", + "reconcile-generic-web-route-binding": "operator.generic-web-route-binding", + "reconcile-generic-web-testing-ingress-route": ( + "operator.generic-web-testing-ingress-route" ), - "reconcile-preview-feedback-remediation": ( - "operator.preview-feedback-remediation" + "reconcile-odoo-route-binding": "operator.odoo-route-binding", + "reconcile-odoo-external-route-binding": "operator.odoo-external-route-binding", + "reconcile-odoo-testing-ingress-route": "operator.odoo-testing-ingress-route", + "reconcile-odoo-testing-route-binding-refresh": ( + "operator.odoo-testing-route-binding-refresh" ), - "reconcile-generic-web-route-binding": ( - "operator.generic-web-route-binding" + "reconcile-odoo-testing-target-replacement": ( + "operator.odoo-testing-target-replacement" ), - "reconcile-generic-web-testing-ingress-route": ( - "operator.generic-web-testing-ingress-route" + "reconcile-odoo-opw-preview-feedback": "operator.odoo-opw-preview-feedback", + "reconcile-odoo-opw-production-enrollment": ( + "operator.odoo-opw-production-enrollment" + ), + "reconcile-odoo-production-enrollment": "operator.odoo-production-enrollment", + "reconcile-odoo-production-operation-read": ( + "operator.odoo-production-operation-read" + ), + "reconcile-odoo-production-backup-restore": ( + "operator.odoo-production-backup-restore" ), } - if job_name in expected_managed_set_ids: - self.assertEqual( - dispatch_inputs["expected_managed_set_id"], - expected_managed_set_ids[job_name], - ) - else: - self.assertNotIn("expected_managed_set_id", dispatch_inputs) + self.assertEqual( + dispatch_inputs["expected_managed_set_id"], + expected_managed_set_ids[job_name], + ) dispatch_secrets = dispatch_job["secrets"] assert isinstance(dispatch_secrets, dict) self.assertEqual(dispatch_secrets["managed_set_json"], expected_secret) - def test_deploy_workflow_bootstraps_managed_authz_without_deploying(self) -> None: + def test_deploy_workflow_does_not_administer_authorization(self) -> None: deploy_job = self.deploy_workflow.job("deploy") - self.assertIn("inputs.authz_managed_mode == 'none'", str(deploy_job["if"])) + self.assertNotIn("authz_managed", str(deploy_job["if"])) self.assertNotIn("operator-authz-grants", self.deploy_workflow.jobs) dispatch = self.deploy_workflow.data["on"] assert isinstance(dispatch, dict) @@ -203,42 +226,18 @@ def test_deploy_workflow_bootstraps_managed_authz_without_deploying(self) -> Non dispatch_inputs = workflow_dispatch["inputs"] assert isinstance(dispatch_inputs, dict) self.assertFalse(any(name.startswith("authz_grants_") for name in dispatch_inputs)) + self.assertFalse(any(name.startswith("authz_managed_") for name in dispatch_inputs)) self.assertNotIn("authz_managed_set", dispatch_inputs) self.assertNotIn( "operator-authz-policy-reconcile-bootstrap", self.deploy_workflow.jobs, ) - managed_job = self.deploy_workflow.job("operator-authz-managed") - self.assertEqual( - self.deploy_workflow.job_uses("operator-authz-managed"), - "cbusillo/launchplane/.github/workflows/reusable-authz-policy-reconcile.yml@" - "4dbef2945b0a297a6edaa949a42d8c7d4cbc01cd", - ) - self.assertEqual(managed_job["needs"], "operator-authz-managed-validate") - self.assertEqual( - self.deploy_workflow.job_permissions("operator-authz-managed"), - {"contents": "read", "id-token": "write"}, - ) - managed_secrets = managed_job["secrets"] - assert isinstance(managed_secrets, dict) - self.assertEqual( - managed_secrets["managed_set_json"], - "${{ secrets.LAUNCHPLANE_AUTHZ_MANAGED_SET_JSON }}", - ) - managed_inputs = managed_job["with"] - assert isinstance(managed_inputs, dict) - self.assertEqual(managed_inputs["mode"], "${{ inputs.authz_managed_mode }}") - self.assertEqual( - managed_inputs["reviewed_plan_sha256"], - "${{ inputs.authz_managed_reviewed_plan_sha256 }}", - ) - validation_step = self.deploy_workflow.step_named( - "operator-authz-managed-validate", "Validate managed authz isolation" + self.assertNotIn("operator-authz-managed", self.deploy_workflow.jobs) + self.assertNotIn("operator-authz-managed-validate", self.deploy_workflow.jobs) + self.assertNotIn( + "LAUNCHPLANE_AUTHZ_MANAGED_SET_JSON", + Path(".github/workflows/deploy-launchplane.yml").read_text(encoding="utf-8"), ) - self.assertIsNotNone(validation_step) - assert validation_step is not None - self.assertIn("cannot include a deploy image input", validation_step.run) - self.assertIn("cannot include break-glass rollback inputs", validation_step.run) def test_reusable_workflow_is_protected_and_service_backed(self) -> None: workflow_call = self.workflow.data["on"] @@ -251,8 +250,8 @@ def test_reusable_workflow_is_protected_and_service_backed(self) -> None: assert isinstance(workflow_call_inputs, dict) expected_managed_set_id = workflow_call_inputs["expected_managed_set_id"] assert isinstance(expected_managed_set_id, dict) - self.assertEqual(expected_managed_set_id["default"], "") - self.assertEqual(expected_managed_set_id["required"], False) + self.assertNotIn("default", expected_managed_set_id) + self.assertEqual(expected_managed_set_id["required"], True) self.assertEqual(expected_managed_set_id["type"], "string") workflow_call_secrets = workflow_call_contract["secrets"] assert isinstance(workflow_call_secrets, dict) @@ -316,6 +315,16 @@ def test_reusable_workflow_is_protected_and_service_backed(self) -> None: "${{ steps.request.outputs.evidence_directory }}", ) self.assertEqual(upload_step.with_values["retention-days"], 30) + verify_step = self.workflow.step_named( + "reconcile", "Verify and summarize managed authz result" + ) + self.assertIsNotNone(verify_step) + assert verify_step is not None + self.assertIn("operational_readiness_blocked_rule_count", verify_step.run) + self.assertIn( + "refused to verify an authz apply with operational-readiness blockers", + verify_step.run, + ) cleanup_step = self.workflow.step_named( "reconcile", "Remove managed authz request material" ) diff --git a/tests/test_generic_web_preview_authz.py b/tests/test_generic_web_preview_authz.py index c1abc221b..ebc76abd1 100644 --- a/tests/test_generic_web_preview_authz.py +++ b/tests/test_generic_web_preview_authz.py @@ -96,6 +96,29 @@ def test_operator_workflow_exposes_scoped_ingress_expansion(self) -> None: self.assertIn("include_ingress_operator", workflow_text) self.assertIn("launchplane_sha", workflow_text) + self.assertIn( + "Generic-web preview authorization must run from the default branch", + workflow_text, + ) + self.assertNotIn("secrets: inherit", workflow_text) + self.assertIn( + 'managed_set_id" != "operator.generic-web-preview"', + workflow_text, + ) + self.assertIn("verify-apply:", workflow_text) + self.assertIn("Download authorization apply evidence", workflow_text) + self.assertIn("group: launchplane-authz-policy", workflow_text) + self.assertIn("Applied preview authorization did not match", workflow_text) + apply_workflow_text = Path( + ".github/workflows/reusable-generic-web-preview-authz-apply.yml" + ).read_text(encoding="utf-8") + self.assertIn("group: launchplane-authz-policy", apply_workflow_text) + self.assertIn("cancel-in-progress: false", apply_workflow_text) + self.assertIn( + 'managed_set_id" != "operator.generic-web-preview"', + apply_workflow_text, + ) + self.assertIn("Verify applied authorization", apply_workflow_text) def test_onboarding_generates_exact_six_rule_contract(self) -> None: rules = generic_web_preview_rules(_request()) diff --git a/tests/test_product_onboarding.py b/tests/test_product_onboarding.py index 02cf0fcfb..dedecdf52 100644 --- a/tests/test_product_onboarding.py +++ b/tests/test_product_onboarding.py @@ -1815,9 +1815,9 @@ def test_deploy_launchplane_routes_dispatch_values_out_of_shell_source(self) -> workflow_text.count( "github.ref == format('refs/heads/{0}', github.event.repository.default_branch)" ), - 3, + 2, ) - self.assertGreaterEqual(workflow_text.count("github.ref_type == 'branch'"), 3) + self.assertGreaterEqual(workflow_text.count("github.ref_type == 'branch'"), 2) self.assertIn( "BREAK_GLASS_IMAGE_REFERENCE: ${{ inputs.break_glass_image_reference }}", emergency_job, @@ -1915,7 +1915,7 @@ def test_deploy_launchplane_rejects_multiline_previous_image_output(self) -> Non def test_deploy_launchplane_break_glass_limits_credentials_and_permissions(self) -> None: workflow_text = Path(".github/workflows/deploy-launchplane.yml").read_text(encoding="utf-8") deploy_job = workflow_text.split(" deploy:\n", 1)[1].split( - " operator-authz-managed-validate:\n", 1 + " emergency-dokploy-rollback:\n", 1 )[0] emergency_job = workflow_text.split(" emergency-dokploy-rollback:\n", 1)[1] validation_step = emergency_job.split("- name: Validate manual break-glass request", 1)[ @@ -1969,10 +1969,14 @@ def test_deploy_workflow_removes_exact_authz_bridge(self) -> None: workflow_text = Path(".github/workflows/deploy-launchplane.yml").read_text(encoding="utf-8") self.assertFalse(Path("scripts/deploy/ensure-authz-grants.sh").exists()) - self.assertIn("operator-authz-managed-validate:", workflow_text) - self.assertIn("operator-authz-managed:", workflow_text) for retired_token in ( + "operator-authz-managed-validate:", + "operator-authz-managed:", "operator-authz-grants:", + "authz_managed_mode", + "authz_managed_reviewed_plan_sha256", + "authz_managed_reason", + "authz_managed_related_issue", "authz_grants_mode", "authz_grants_expected_sha256", "authz_policy_expected_sha256", diff --git a/tests/test_service.py b/tests/test_service.py index 707e39b98..5cca36741 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -5703,6 +5703,15 @@ def test_generic_web_preview_authz_plan_reuses_managed_reconcile_contract(self) ], } ], + "local_admins": [ + { + "subjects": ["recovery-admin"], + "token_labels": ["recovery-admin"], + "products": ["launchplane"], + "contexts": ["launchplane"], + "actions": ["authz_policy_grant.write"], + } + ], } ) app = create_launchplane_fastapi_test_app( @@ -6072,7 +6081,16 @@ def test_managed_authz_reconcile_migrates_policy_and_rejects_stale_digest(self) "contexts": ["launchplane"], "actions": ["product_profile.read"], }, - ] + ], + "local_admins": [ + { + "subjects": ["recovery-admin"], + "token_labels": ["recovery-admin"], + "products": ["launchplane"], + "contexts": ["launchplane"], + "actions": ["authz_policy_grant.write"], + } + ], } ) app = create_launchplane_fastapi_test_app( @@ -6181,7 +6199,7 @@ def test_managed_authz_reconcile_migrates_policy_and_rejects_stale_digest(self) self.assertEqual(active_status, 200) self.assertEqual(active_payload["policy"]["revision"], 2) self.assertEqual(active_payload["policy"]["managed_rule_count"], 1) - self.assertEqual(active_payload["policy"]["unmanaged_rule_count"], 1) + self.assertEqual(active_payload["policy"]["unmanaged_rule_count"], 2) self.assertEqual( active_payload["policy"]["github_actions_privileged_unpinned_reusable_rule_count"], 1, diff --git a/tests/test_service_auth.py b/tests/test_service_auth.py index d70291fd4..adacc5e5a 100644 --- a/tests/test_service_auth.py +++ b/tests/test_service_auth.py @@ -381,6 +381,63 @@ def test_human_read_only_role_fails_closed_for_privileged_actions(self) -> None: ) ) + def test_human_rule_without_principal_or_role_preserves_legacy_runtime_semantics( + self, + ) -> None: + identity = _human_identity(role="admin") + + for rule in ( + GitHubHumanPolicyRule( + roles=("admin",), + products=("launchplane",), + contexts=("launchplane",), + actions=("authz_policy_grant.write",), + ), + GitHubHumanPolicyRule( + github_ids=(identity.github_id,), + products=("launchplane",), + contexts=("launchplane",), + actions=("authz_policy_grant.write",), + ), + ): + with self.subTest(rule=rule.model_dump(mode="json")): + self.assertTrue( + rule.allows( + identity=identity, + action="authz_policy_grant.write", + product="launchplane", + context="launchplane", + ) + ) + self.assertTrue( + rule.matches_principal( + github_id=identity.github_id, + login=identity.login, + organizations=identity.organizations, + teams=identity.teams, + role=identity.role, + ) + ) + + def test_human_principal_selectors_preserve_legacy_glob_runtime_semantics(self) -> None: + identity = _human_identity(role="read_only") + + for rule in ( + GitHubHumanPolicyRule(logins=("*",), roles=("read_only",)), + GitHubHumanPolicyRule(organizations=("*",), roles=("read_only",)), + GitHubHumanPolicyRule(teams=("*",), roles=("read_only",)), + ): + with self.subTest(rule=rule.model_dump(mode="json")): + self.assertTrue( + rule.matches_principal( + github_id=identity.github_id, + login=identity.login, + organizations=identity.organizations, + teams=identity.teams, + role=identity.role, + ) + ) + def test_agent_authz_audit_records_safe_denial_metadata(self) -> None: audit = agent_authz_audit( identity=_actions_identity(), diff --git a/tests/test_service_bootstrap.py b/tests/test_service_bootstrap.py index defdf50b8..30c18ca59 100644 --- a/tests/test_service_bootstrap.py +++ b/tests/test_service_bootstrap.py @@ -184,6 +184,13 @@ def capture_uvicorn_run(app: Any, **_kwargs: object) -> None: 'contexts = ["launchplane"]', 'actions = ["authz_policy_grant.write"]', "", + "[[local_admins]]", + 'subjects = ["recovery-admin"]', + 'token_labels = ["recovery-admin"]', + 'products = ["launchplane"]', + 'contexts = ["launchplane"]', + 'actions = ["authz_policy_grant.write"]', + "", ) ), encoding="utf-8", diff --git a/tests/test_service_human_auth.py b/tests/test_service_human_auth.py index aedb7d9bb..a2fe7737a 100644 --- a/tests/test_service_human_auth.py +++ b/tests/test_service_human_auth.py @@ -3,7 +3,11 @@ from datetime import datetime, timedelta, timezone import unittest -from control_plane.service_auth import GitHubHumanIdentity, LaunchplaneAuthzPolicy +from control_plane.service_auth import ( + GitHubHumanIdentity, + GitHubHumanPolicyRule, + LaunchplaneAuthzPolicy, +) from control_plane.service_human_auth import ( GitHubOAuthConfig, HumanSessionManager, @@ -37,7 +41,7 @@ def _identity() -> GitHubHumanIdentity: class HumanSessionManagerTests(unittest.TestCase): - def test_bootstrap_admin_role_survives_db_policy_revalidation(self) -> None: + def test_bootstrap_admin_role_applies_before_db_human_policy_exists(self) -> None: manager = HumanSessionManager( config=GitHubOAuthConfig( client_id="client-id", @@ -57,6 +61,55 @@ def test_bootstrap_admin_role_survives_db_policy_revalidation(self) -> None: "admin", ) + def test_bootstrap_admin_role_stops_after_db_human_policy_exists(self) -> None: + manager = HumanSessionManager( + config=GitHubOAuthConfig( + client_id="client-id", + client_secret="client-secret", + public_url="https://launchplane.example", + session_secret="session-secret", + bootstrap_admin_emails=frozenset({"alice@example.com"}), + ), + session_store=InMemoryHumanSessionStore(), + ) + policy = LaunchplaneAuthzPolicy( + github_humans=( + GitHubHumanPolicyRule( + github_ids=(999,), + roles=("admin",), + products=("launchplane",), + contexts=("launchplane",), + actions=("authz_policy_grant.write",), + ), + ) + ) + + self.assertIsNone(manager.authorized_role(identity=_identity(), authz_policy=policy)) + + def test_legacy_human_policy_does_not_disable_bootstrap_admin(self) -> None: + manager = HumanSessionManager( + config=GitHubOAuthConfig( + client_id="client-id", + client_secret="client-secret", + public_url="https://launchplane.example", + session_secret="session-secret", + bootstrap_admin_emails=frozenset({"alice@example.com"}), + ), + session_store=InMemoryHumanSessionStore(), + ) + policy = LaunchplaneAuthzPolicy( + github_humans=( + GitHubHumanPolicyRule( + logins=("*",), + actions=("authz_policy_grant.write",), + ), + ) + ) + + self.assertEqual( + manager.authorized_role(identity=_identity(), authz_policy=policy), "admin" + ) + def test_session_cookie_is_signed_and_round_trips(self) -> None: store = InMemoryHumanSessionStore() manager = HumanSessionManager(config=_config(), session_store=store) From fde5bbd5f6b70a85e90fdd26d462e5fea542af2a Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 18 Aug 2026 10:25:12 -0400 Subject: [PATCH 2/2] Harden authz dry-run safety evidence --- .../generic-web-preview-authorization.yml | 4 +- ...usable-generic-web-preview-authz-apply.yml | 4 - control_plane/authz_grant_service.py | 502 +++++++++++------- control_plane/service_human_auth.py | 92 ++-- docs/operations.md | 12 + tests/test_authz_grant_service.py | 205 ++++--- tests/test_generic_web_preview_authz.py | 3 +- tests/test_github_human_policy_rule.py | 103 ++++ tests/test_service_human_auth.py | 98 ++++ 9 files changed, 712 insertions(+), 311 deletions(-) create mode 100644 tests/test_github_human_policy_rule.py diff --git a/.github/workflows/generic-web-preview-authorization.yml b/.github/workflows/generic-web-preview-authorization.yml index 781cb505a..a199f778f 100644 --- a/.github/workflows/generic-web-preview-authorization.yml +++ b/.github/workflows/generic-web-preview-authorization.yml @@ -76,7 +76,7 @@ jobs: product: ${{ steps.inputs.outputs.product }} env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GITHUB_REF_NAME: ${{ github.ref }} + WORKFLOW_REF: ${{ github.ref }} LAUNCHPLANE_URL: ${{ vars.LAUNCHPLANE_PUBLIC_URL }} MODE: ${{ inputs.mode }} OPERATION: ${{ inputs.operation }} @@ -114,7 +114,7 @@ jobs: mode = required("MODE") expected_ref = f"refs/heads/{os.environ['DEFAULT_BRANCH']}" - if os.environ["GITHUB_REF_NAME"] != expected_ref: + if os.environ["WORKFLOW_REF"] != expected_ref: raise SystemExit( "Generic-web preview authorization must run from the default branch." ) diff --git a/.github/workflows/reusable-generic-web-preview-authz-apply.yml b/.github/workflows/reusable-generic-web-preview-authz-apply.yml index af44e39aa..8ed7ddb47 100644 --- a/.github/workflows/reusable-generic-web-preview-authz-apply.yml +++ b/.github/workflows/reusable-generic-web-preview-authz-apply.yml @@ -17,10 +17,6 @@ permissions: contents: read id-token: write -concurrency: - group: launchplane-authz-policy - cancel-in-progress: false - jobs: apply: runs-on: ubuntu-latest diff --git a/control_plane/authz_grant_service.py b/control_plane/authz_grant_service.py index d23cd8aec..33a14a430 100644 --- a/control_plane/authz_grant_service.py +++ b/control_plane/authz_grant_service.py @@ -1,11 +1,12 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass from fnmatch import fnmatchcase import hashlib import json import re -from typing import Any, Literal, Protocol, cast +from typing import Any, Literal, Protocol, TypeAlias, cast, overload from pydantic import BaseModel, ConfigDict, model_validator @@ -36,7 +37,6 @@ GitHubHumanPolicyRule, GitHubHumanIdentity, LaunchplaneAuthzPolicy, - LaunchplaneIdentity, LocalAdminIdentity, LocalAdminPolicyRule, LocalOperatorIdentity, @@ -71,20 +71,46 @@ def _require_expected_authz_policy( ) -AuthzPolicyRule = ( +AuthzPolicyRule: TypeAlias = ( GitHubActionsPolicyRule | GitHubHumanPolicyRule | TerminalAgentPolicyRule | LocalOperatorPolicyRule | LocalAdminPolicyRule ) -AuthzPrincipalType = Literal[ +AuthzApplyingIdentity: TypeAlias = ( + GitHubActionsIdentity + | GitHubHumanIdentity + | TerminalAgentIdentity + | LocalOperatorIdentity + | LocalAdminIdentity +) +AuthzPrincipalType: TypeAlias = Literal[ "github_actions", "github_humans", "terminal_agents", "local_operators", "local_admins", ] +_AUTHZ_PRINCIPAL_TYPES: tuple[AuthzPrincipalType, ...] = ( + "github_actions", + "github_humans", + "terminal_agents", + "local_operators", + "local_admins", +) + + +@dataclass(frozen=True) +class AuthzRuleLocation: + principal_type: AuthzPrincipalType + index: int + + +@dataclass(frozen=True) +class AuthzManagedRuleEntry: + principal_type: AuthzPrincipalType + rule: AuthzPolicyRule def _authz_policy_rule_collections( @@ -118,6 +144,26 @@ def _authz_policy_rule_collections( ) +@overload +def _normalize_authz_rule(rule: GitHubActionsPolicyRule) -> GitHubActionsPolicyRule: ... + + +@overload +def _normalize_authz_rule(rule: GitHubHumanPolicyRule) -> GitHubHumanPolicyRule: ... + + +@overload +def _normalize_authz_rule(rule: TerminalAgentPolicyRule) -> TerminalAgentPolicyRule: ... + + +@overload +def _normalize_authz_rule(rule: LocalOperatorPolicyRule) -> LocalOperatorPolicyRule: ... + + +@overload +def _normalize_authz_rule(rule: LocalAdminPolicyRule) -> LocalAdminPolicyRule: ... + + def _normalize_authz_rule(rule: AuthzPolicyRule) -> AuthzPolicyRule: updates: dict[str, object] = {} for field_name in _AUTHZ_RULE_SELECTOR_FIELDS: @@ -133,33 +179,13 @@ def _normalize_desired_authz_policy(policy: LaunchplaneAuthzPolicy) -> Launchpla principal_type: tuple( sorted( (_normalize_authz_rule(rule) for rule in rules), - key=lambda rule: str(rule.managed_rule_id), + key=lambda rule: rule.managed_rule_id or "", ) ) for principal_type, rules in _authz_policy_rule_collections(policy) } - return LaunchplaneAuthzPolicy( - schema_version=policy.schema_version, - github_actions=cast( - tuple[GitHubActionsPolicyRule, ...], - normalized_collections["github_actions"], - ), - github_humans=cast( - tuple[GitHubHumanPolicyRule, ...], - normalized_collections["github_humans"], - ), - terminal_agents=cast( - tuple[TerminalAgentPolicyRule, ...], - normalized_collections["terminal_agents"], - ), - local_operators=cast( - tuple[LocalOperatorPolicyRule, ...], - normalized_collections["local_operators"], - ), - local_admins=cast( - tuple[LocalAdminPolicyRule, ...], - normalized_collections["local_admins"], - ), + return LaunchplaneAuthzPolicy.model_validate( + {"schema_version": policy.schema_version, **normalized_collections} ) @@ -192,8 +218,8 @@ def _normalize_desired_authz_policy(policy: LaunchplaneAuthzPolicy) -> Launchpla PRODUCT_OWNER_REQUIREMENT_WRITE_ACTION, } ) -AuthzSchemaMigrationMode = Literal["reject", "migrate_v1_to_v2"] -AuthzUnmanagedAdoptionMode = Literal["reject", "adopt_matching"] +AuthzSchemaMigrationMode: TypeAlias = Literal["reject", "migrate_v1_to_v2"] +AuthzUnmanagedAdoptionMode: TypeAlias = Literal["reject", "adopt_matching"] def _validate_owner_acceptance_managed_set(policy: LaunchplaneAuthzPolicy) -> None: @@ -315,7 +341,8 @@ def _validate_reconcile(self) -> "AuthzManagedPolicyReconcileEnvelope": _validate_product_owner_policy_admin_managed_set(self.desired_policy) for principal_type, rules in _authz_policy_rule_collections(self.desired_policy): for rule in rules: - if rule.managed_set_id != self.managed_set_id or not rule.managed_rule_id: + managed_rule_id = rule.managed_rule_id + if rule.managed_set_id != self.managed_set_id or managed_rule_id is None: raise ValueError( "Every desired managed authz rule must declare the request managed_set_id " f"and a managed_rule_id ({principal_type})." @@ -323,25 +350,25 @@ def _validate_reconcile(self) -> "AuthzManagedPolicyReconcileEnvelope": if not rule.actions: raise ValueError( "Every desired managed authz rule must declare at least one action " - f"({principal_type}:{rule.managed_rule_id})." + f"({principal_type}:{managed_rule_id})." ) if isinstance(rule, GitHubActionsPolicyRule) and ( not rule.repository_id or not rule.repository_owner_id ): raise ValueError( "Managed GitHub Actions authz rules require immutable repository_id " - f"and repository_owner_id selectors ({rule.managed_rule_id})." + f"and repository_owner_id selectors ({managed_rule_id})." ) if isinstance(rule, GitHubHumanPolicyRule): if not rule.roles: raise ValueError( "Managed GitHub human authz rules require at least one explicit role " - f"({rule.managed_rule_id})." + f"({managed_rule_id})." ) if not any((rule.github_ids, rule.logins, rule.organizations, rule.teams)): raise ValueError( "Managed GitHub human authz rules require at least one principal " - f"selector ({rule.managed_rule_id})." + f"selector ({managed_rule_id})." ) if any( _contains_selector_glob(selector) @@ -349,7 +376,7 @@ def _validate_reconcile(self) -> "AuthzManagedPolicyReconcileEnvelope": ): raise ValueError( "Managed GitHub human authz rules require exact login, organization, " - f"and team selectors ({rule.managed_rule_id})." + f"and team selectors ({managed_rule_id})." ) if ( "admin" in rule.roles @@ -360,7 +387,7 @@ def _validate_reconcile(self) -> "AuthzManagedPolicyReconcileEnvelope": ) and not rule.github_ids: raise ValueError( "Managed GitHub human admin and sensitive-action rules require " - f"immutable github_ids ({rule.managed_rule_id})." + f"immutable github_ids ({managed_rule_id})." ) return self @@ -376,7 +403,7 @@ class AuthzManagedPolicyRouteResult(BaseModel): driver_result: dict[str, object] -AuthzManagedRuleChangeKind = Literal["added", "adopted", "updated", "removed"] +AuthzManagedRuleChangeKind: TypeAlias = Literal["added", "adopted", "updated", "removed"] class AuthzManagedRuleChange(BaseModel): @@ -402,7 +429,7 @@ class AuthzManagedCompatibilityRetirement(BaseModel): ) -AuthzOperationalReadinessBlockerCode = Literal[ +AuthzOperationalReadinessBlockerCode: TypeAlias = Literal[ "repository_not_exact", "workflow_refs_not_singleton", "workflow_ref_not_exact", @@ -427,6 +454,20 @@ class AuthzManagedOperationalReadinessBlocker(BaseModel): reason_codes: tuple[AuthzOperationalReadinessBlockerCode, ...] +AuthzManagedPolicySafetyBlockerCode: TypeAlias = Literal[ + "authz_policy_admin_unreachable", + "authz_policy_applying_admin_removed", + "authz_policy_independent_admin_unreachable", +] + + +class AuthzManagedPolicySafetyBlocker(BaseModel): + model_config = ConfigDict(extra="forbid") + + code: AuthzManagedPolicySafetyBlockerCode + message: str + + class AuthzManagedPolicyDiff(BaseModel): model_config = ConfigDict(extra="forbid") @@ -449,11 +490,33 @@ class AuthzManagedPolicyDiff(BaseModel): unmanaged_compatibility_candidate_count: int = 0 retired_unmanaged_compatibility_rule_count: int = 0 retired_unmanaged_compatibility_rules: tuple[AuthzManagedCompatibilityRetirement, ...] = () + policy_safety_blocker_count: int = 0 + policy_safety_blockers: tuple[AuthzManagedPolicySafetyBlocker, ...] = () operational_readiness_blocked_rule_count: int = 0 operational_readiness_blockers: tuple[AuthzManagedOperationalReadinessBlocker, ...] = () changes: tuple[AuthzManagedRuleChange, ...] = () +def _managed_policy_safety_blocker( + code: AuthzManagedPolicySafetyBlockerCode, +) -> AuthzManagedPolicySafetyBlocker: + messages = { + "authz_policy_admin_unreachable": ( + "Managed authz policy reconciliation must retain at least one reachable principal " + "that can administer Launchplane authz policy." + ), + "authz_policy_applying_admin_removed": ( + "Managed authz policy reconciliation must retain policy administration " + "authority for the applying identity." + ), + "authz_policy_independent_admin_unreachable": ( + "Managed authz policy reconciliation must retain a reachable policy " + "administrator independent from the applying identity." + ), + } + return AuthzManagedPolicySafetyBlocker(code=code, message=messages[code]) + + def summarize_authz_policy_record(record: LaunchplaneAuthzPolicyRecord) -> dict[str, object]: immutable_repository_rule_count = sum( 1 for rule in record.policy.github_actions if rule.repository_id @@ -516,7 +579,7 @@ def summarize_active_authz_policy_record( return summary -def authz_policy_operator_payload(identity: LaunchplaneIdentity) -> dict[str, object]: +def authz_policy_operator_payload(identity: AuthzApplyingIdentity) -> dict[str, object]: if isinstance(identity, GitHubHumanIdentity): return { "type": "github_human", @@ -578,7 +641,35 @@ def _authz_rule_sha256(rule: AuthzPolicyRule) -> str: sort_keys=True, separators=(",", ":"), ) - return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest() + return hashlib.sha256(canonical_json.encode()).hexdigest() + + +@overload +def _authz_rule_without_managed_identity( + rule: GitHubActionsPolicyRule, +) -> GitHubActionsPolicyRule: ... + + +@overload +def _authz_rule_without_managed_identity( + rule: GitHubHumanPolicyRule, +) -> GitHubHumanPolicyRule: ... + + +@overload +def _authz_rule_without_managed_identity( + rule: TerminalAgentPolicyRule, +) -> TerminalAgentPolicyRule: ... + + +@overload +def _authz_rule_without_managed_identity( + rule: LocalOperatorPolicyRule, +) -> LocalOperatorPolicyRule: ... + + +@overload +def _authz_rule_without_managed_identity(rule: LocalAdminPolicyRule) -> LocalAdminPolicyRule: ... def _authz_rule_without_managed_identity(rule: AuthzPolicyRule) -> AuthzPolicyRule: @@ -659,9 +750,13 @@ def _managed_operational_readiness_blockers( reason_codes = operational_readiness_rule_selector_blockers(rule) if not reason_codes: continue + if rule.managed_rule_id is None: + raise AuthzPolicyRequestError( + "Operational-readiness managed authz rules require managed_rule_id." + ) blockers.append( AuthzManagedOperationalReadinessBlocker( - managed_rule_id=str(rule.managed_rule_id), + managed_rule_id=rule.managed_rule_id, actions=matching_actions, reason_codes=reason_codes, ) @@ -753,17 +848,22 @@ def _validate_github_managed_workflow_transition( assert isinstance(desired_rule, GitHubActionsPolicyRule) if not _github_rule_requires_immutable_workflow(desired_rule): continue + managed_rule_id = desired_rule.managed_rule_id + if managed_rule_id is None: + raise AuthzPolicyRequestError( + "High-privilege managed GitHub Actions rules require managed_rule_id." + ) if not desired_rule.workflow_refs or any( _contains_selector_glob(workflow_ref) for workflow_ref in desired_rule.workflow_refs ): raise AuthzPolicyRequestError( "High-privilege managed GitHub Actions rules require exact caller workflow_refs " - f"({desired_rule.managed_rule_id})." + f"({managed_rule_id})." ) if not desired_rule.job_workflow_refs: raise AuthzPolicyRequestError( "High-privilege managed GitHub Actions rules require an exact reviewed reusable " - f"workflow identity ({desired_rule.managed_rule_id})." + f"workflow identity ({managed_rule_id})." ) if not any( is_immutable_job_workflow_ref(job_workflow_ref) @@ -772,7 +872,7 @@ def _validate_github_managed_workflow_transition( raise AuthzPolicyRequestError( "High-privilege managed GitHub Actions rules require at least one reusable " "workflow identity pinned to a full commit SHA " - f"({desired_rule.managed_rule_id})." + f"({managed_rule_id})." ) for job_workflow_ref in desired_rule.job_workflow_refs: if is_immutable_job_workflow_ref(job_workflow_ref): @@ -788,7 +888,7 @@ def _validate_github_managed_workflow_transition( raise AuthzPolicyRequestError( "High-privilege managed GitHub Actions rules can preserve a mutable reusable " "workflow ref only when the active policy already authorizes that exact ref; " - f"new refs must use a full commit SHA ({desired_rule.managed_rule_id})." + f"new refs must use a full commit SHA ({managed_rule_id})." ) @@ -876,8 +976,8 @@ def _managed_github_compatibility_retirement_matches( or current_rule.repository_owner_id ): return False - normalized_current_rule = cast(GitHubActionsPolicyRule, _normalize_authz_rule(current_rule)) - normalized_desired_rule = cast(GitHubActionsPolicyRule, _normalize_authz_rule(desired_rule)) + normalized_current_rule = _normalize_authz_rule(current_rule) + normalized_desired_rule = _normalize_authz_rule(desired_rule) return ( normalized_current_rule.repository == normalized_desired_rule.repository and bool(normalized_current_rule.actions) @@ -919,25 +1019,35 @@ def _managed_github_compatibility_retirement_matches( def _managed_rules_by_id( *, policy: LaunchplaneAuthzPolicy, managed_set_id: str -) -> dict[str, tuple[AuthzPrincipalType, AuthzPolicyRule]]: - return { - str(rule.managed_rule_id): (principal_type, rule) - for principal_type, rules in _authz_policy_rule_collections(policy) - for rule in rules - if rule.managed_set_id == managed_set_id and rule.managed_rule_id is not None - } +) -> dict[str, AuthzManagedRuleEntry]: + managed_rules: dict[str, AuthzManagedRuleEntry] = {} + for principal_type, rules in _authz_policy_rule_collections(policy): + for rule in rules: + if rule.managed_set_id != managed_set_id or rule.managed_rule_id is None: + continue + managed_rules[rule.managed_rule_id] = AuthzManagedRuleEntry( + principal_type=principal_type, + rule=rule, + ) + return managed_rules def _desired_managed_set_payload(policy: LaunchplaneAuthzPolicy) -> list[dict[str, object]]: - return [ - { - "principal_type": principal_type, - "managed_rule_id": str(rule.managed_rule_id), - "rule": rule.model_dump(mode="json", exclude_none=True), - } - for principal_type, rules in _authz_policy_rule_collections(policy) - for rule in rules - ] + payload: list[dict[str, object]] = [] + for principal_type, rules in _authz_policy_rule_collections(policy): + for rule in rules: + if rule.managed_rule_id is None: + raise AuthzPolicyRequestError( + "Desired managed authz rules require managed_rule_id." + ) + payload.append( + { + "principal_type": principal_type, + "managed_rule_id": rule.managed_rule_id, + "rule": rule.model_dump(mode="json", exclude_none=True), + } + ) + return payload def _authz_policy_without_managed_identities( @@ -947,13 +1057,8 @@ def _authz_policy_without_managed_identities( principal_type: tuple(_authz_rule_without_managed_identity(rule) for rule in rules) for principal_type, rules in _authz_policy_rule_collections(policy) } - return LaunchplaneAuthzPolicy( - schema_version=policy.schema_version, - github_actions=cast(tuple[GitHubActionsPolicyRule, ...], collections["github_actions"]), - github_humans=cast(tuple[GitHubHumanPolicyRule, ...], collections["github_humans"]), - terminal_agents=cast(tuple[TerminalAgentPolicyRule, ...], collections["terminal_agents"]), - local_operators=cast(tuple[LocalOperatorPolicyRule, ...], collections["local_operators"]), - local_admins=cast(tuple[LocalAdminPolicyRule, ...], collections["local_admins"]), + return LaunchplaneAuthzPolicy.model_validate( + {"schema_version": policy.schema_version, **collections} ) @@ -997,54 +1102,42 @@ def _authz_policy_retains_administration(policy: LaunchplaneAuthzPolicy) -> bool def _authz_rule_allows_identity( *, rule: AuthzPolicyRule, - identity: LaunchplaneIdentity, + identity: AuthzApplyingIdentity, schema_version: Literal[1, 2], ) -> bool: - if isinstance(rule, GitHubActionsPolicyRule) and isinstance(identity, GitHubActionsIdentity): - return rule.allows( - identity=identity, - action=_AUTHZ_POLICY_ADMIN_ACTION, - product="launchplane", - context="launchplane", - schema_version=schema_version, - ) - if isinstance(rule, GitHubHumanPolicyRule) and isinstance(identity, GitHubHumanIdentity): - return rule.allows( - identity=identity, - action=_AUTHZ_POLICY_ADMIN_ACTION, - product="launchplane", - context="launchplane", - schema_version=schema_version, - ) - if isinstance(rule, TerminalAgentPolicyRule) and isinstance(identity, TerminalAgentIdentity): - return rule.allows( - identity=identity, - action=_AUTHZ_POLICY_ADMIN_ACTION, - product="launchplane", - context="launchplane", - schema_version=schema_version, - ) - if isinstance(rule, LocalOperatorPolicyRule) and isinstance(identity, LocalOperatorIdentity): - return rule.allows( - identity=identity, - action=_AUTHZ_POLICY_ADMIN_ACTION, - product="launchplane", - context="launchplane", - schema_version=schema_version, + identity_matches_rule = any( + ( + ( + isinstance(rule, GitHubActionsPolicyRule) + and isinstance(identity, GitHubActionsIdentity) + ), + (isinstance(rule, GitHubHumanPolicyRule) and isinstance(identity, GitHubHumanIdentity)), + ( + isinstance(rule, TerminalAgentPolicyRule) + and isinstance(identity, TerminalAgentIdentity) + ), + ( + isinstance(rule, LocalOperatorPolicyRule) + and isinstance(identity, LocalOperatorIdentity) + ), + (isinstance(rule, LocalAdminPolicyRule) and isinstance(identity, LocalAdminIdentity)), ) - if isinstance(rule, LocalAdminPolicyRule) and isinstance(identity, LocalAdminIdentity): - return rule.allows( + ) + if not identity_matches_rule: + return False + return bool( + cast(Any, rule).allows( identity=identity, action=_AUTHZ_POLICY_ADMIN_ACTION, product="launchplane", context="launchplane", schema_version=schema_version, ) - return False + ) def _authz_policy_retains_independent_administration( - *, policy: LaunchplaneAuthzPolicy, applying_identity: LaunchplaneIdentity + *, policy: LaunchplaneAuthzPolicy, applying_identity: AuthzApplyingIdentity ) -> bool: return any( not _authz_rule_allows_identity( @@ -1077,16 +1170,16 @@ def _reconcile_managed_policy( policy=desired_policy, managed_set_id=managed_set_id, ) - current_collections = dict(_authz_policy_rule_collections(current_policy)) - adoption_locations: dict[tuple[AuthzPrincipalType, int], str] = {} + adoption_locations: dict[AuthzRuleLocation, str] = {} adopted_rule_ids: set[str] = set() - for managed_rule_id, (desired_principal_type, desired_rule) in sorted( - desired_managed_rules.items() - ): + for managed_rule_id in sorted(desired_managed_rules): + desired_entry = desired_managed_rules[managed_rule_id] + desired_principal_type = desired_entry.principal_type + desired_rule = desired_entry.rule if managed_rule_id in current_managed_rules: continue - candidates = tuple( - (principal_type, index) + candidates: tuple[AuthzRuleLocation, ...] = tuple( + AuthzRuleLocation(principal_type=principal_type, index=index) for principal_type, rules in _authz_policy_rule_collections(current_policy) for index, current_rule in enumerate(rules) if current_rule.managed_set_id is None @@ -1123,41 +1216,38 @@ def _reconcile_managed_policy( "with unmanaged_adoption='adopt_matching'." ) candidate = candidates[0] - if candidate[0] != desired_principal_type or candidate in adoption_locations: + if candidate.principal_type != desired_principal_type or candidate in adoption_locations: raise AuthzPolicyConflictError( "Managed authz policy adoption is ambiguous across desired managed identities." ) adoption_locations[candidate] = managed_rule_id adopted_rule_ids.add(managed_rule_id) - retirement_matches_by_location: dict[tuple[AuthzPrincipalType, int], tuple[str, ...]] = {} - retirement_locations_by_managed_rule_id: dict[ - str, tuple[tuple[AuthzPrincipalType, int], ...] - ] = {} + retirement_matches_by_location: dict[AuthzRuleLocation, tuple[str, ...]] = {} + retirement_locations_by_managed_rule_id: dict[str, tuple[AuthzRuleLocation, ...]] = {} for principal_type, rules in _authz_policy_rule_collections(current_policy): for index, current_rule in enumerate(rules): if principal_type != "github_actions" or current_rule.managed_set_id is not None: continue matching_managed_rule_ids = tuple( managed_rule_id - for managed_rule_id, (desired_principal_type, desired_rule) in sorted( - desired_managed_rules.items() - ) - if desired_principal_type == "github_actions" + for managed_rule_id, desired_entry in sorted(desired_managed_rules.items()) + if desired_entry.principal_type == "github_actions" and _managed_github_compatibility_retirement_matches( current_rule=current_rule, - desired_rule=desired_rule, + desired_rule=desired_entry.rule, ) ) if matching_managed_rule_ids: - retirement_matches_by_location[(principal_type, index)] = matching_managed_rule_ids + location = AuthzRuleLocation(principal_type=principal_type, index=index) + retirement_matches_by_location[location] = matching_managed_rule_ids for managed_rule_id in matching_managed_rule_ids: retirement_locations_by_managed_rule_id[managed_rule_id] = ( *retirement_locations_by_managed_rule_id.get(managed_rule_id, ()), - (principal_type, index), + location, ) - retirement_candidates = { + retirement_candidates: dict[AuthzRuleLocation, str] = { location: managed_rule_ids[0] for location, managed_rule_ids in retirement_matches_by_location.items() if len(managed_rule_ids) == 1 @@ -1165,7 +1255,7 @@ def _reconcile_managed_policy( and current_managed_rules.get(managed_rule_ids[0]) == desired_managed_rules[managed_rule_ids[0]] } - retirement_locations: dict[tuple[AuthzPrincipalType, int], str] = {} + retirement_locations: dict[AuthzRuleLocation, str] = {} if unmanaged_adoption == "adopt_matching": ambiguous_locations = tuple( location @@ -1190,29 +1280,37 @@ def _reconcile_managed_policy( retirement_locations[location] = managed_rule_id updated_collections: dict[AuthzPrincipalType, list[AuthzPolicyRule]] = { - principal_type: [] for principal_type in current_collections + principal_type: [] for principal_type in _AUTHZ_PRINCIPAL_TYPES } placed_desired_rule_ids: set[str] = set() compatibility_retirements: list[AuthzManagedCompatibilityRetirement] = [] for principal_type, current_rules in _authz_policy_rule_collections(current_policy): for index, current_rule in enumerate(current_rules): if current_rule.managed_set_id == managed_set_id: - managed_rule_id = str(current_rule.managed_rule_id) - desired_entry = desired_managed_rules.get(managed_rule_id) - if desired_entry is not None and desired_entry[0] == principal_type: - updated_collections[principal_type].append(desired_entry[1]) + if current_rule.managed_rule_id is None: + raise AuthzPolicyConflictError( + "Managed authz policy contains a managed rule without managed_rule_id." + ) + managed_rule_id = current_rule.managed_rule_id + matching_desired_entry = desired_managed_rules.get(managed_rule_id) + if ( + matching_desired_entry is not None + and matching_desired_entry.principal_type == principal_type + ): + updated_collections[principal_type].append(matching_desired_entry.rule) placed_desired_rule_ids.add(managed_rule_id) continue - adopted_rule_id = adoption_locations.get((principal_type, index)) + location = AuthzRuleLocation(principal_type=principal_type, index=index) + adopted_rule_id = adoption_locations.get(location) if adopted_rule_id is not None: updated_collections[principal_type].append( - desired_managed_rules[adopted_rule_id][1] + desired_managed_rules[adopted_rule_id].rule ) placed_desired_rule_ids.add(adopted_rule_id) continue - retired_managed_rule_id = retirement_locations.get((principal_type, index)) + retired_managed_rule_id = retirement_locations.get(location) if retired_managed_rule_id is not None: - desired_rule = desired_managed_rules[retired_managed_rule_id][1] + desired_rule = desired_managed_rules[retired_managed_rule_id].rule compatibility_retirements.append( AuthzManagedCompatibilityRetirement( managed_rule_id=retired_managed_rule_id, @@ -1223,71 +1321,54 @@ def _reconcile_managed_policy( continue updated_collections[principal_type].append(current_rule) - for managed_rule_id, (principal_type, desired_rule) in sorted(desired_managed_rules.items()): + for managed_rule_id in sorted(desired_managed_rules): if managed_rule_id in placed_desired_rule_ids: continue - updated_collections[principal_type].append(desired_rule) + unplaced_desired_entry = desired_managed_rules[managed_rule_id] + updated_collections[unplaced_desired_entry.principal_type].append( + unplaced_desired_entry.rule + ) - updated_policy = LaunchplaneAuthzPolicy( - schema_version=2, - github_actions=cast( - tuple[GitHubActionsPolicyRule, ...], - tuple(updated_collections["github_actions"]), - ), - github_humans=cast( - tuple[GitHubHumanPolicyRule, ...], - tuple(updated_collections["github_humans"]), - ), - terminal_agents=cast( - tuple[TerminalAgentPolicyRule, ...], - tuple(updated_collections["terminal_agents"]), - ), - local_operators=cast( - tuple[LocalOperatorPolicyRule, ...], - tuple(updated_collections["local_operators"]), - ), - local_admins=cast( - tuple[LocalAdminPolicyRule, ...], - tuple(updated_collections["local_admins"]), - ), + updated_policy = LaunchplaneAuthzPolicy.model_validate( + {"schema_version": 2, **updated_collections} ) changes: list[AuthzManagedRuleChange] = [] unchanged_rule_count = 0 for managed_rule_id in sorted(current_managed_rules.keys() | desired_managed_rules.keys()): current_entry = current_managed_rules.get(managed_rule_id) - desired_entry = desired_managed_rules.get(managed_rule_id) + desired_change_entry = desired_managed_rules.get(managed_rule_id) if current_entry is None: - assert desired_entry is not None + assert desired_change_entry is not None changes.append( AuthzManagedRuleChange( managed_rule_id=managed_rule_id, change="adopted" if managed_rule_id in adopted_rule_ids else "added", - desired_principal_type=desired_entry[0], - desired_rule_sha256=_authz_rule_sha256(desired_entry[1]), + desired_principal_type=desired_change_entry.principal_type, + desired_rule_sha256=_authz_rule_sha256(desired_change_entry.rule), ) ) continue - if desired_entry is None: + if desired_change_entry is None: changes.append( AuthzManagedRuleChange( managed_rule_id=managed_rule_id, change="removed", - previous_principal_type=current_entry[0], - previous_rule_sha256=_authz_rule_sha256(current_entry[1]), + previous_principal_type=current_entry.principal_type, + previous_rule_sha256=_authz_rule_sha256(current_entry.rule), ) ) continue - if current_entry == desired_entry: + if current_entry == desired_change_entry: unchanged_rule_count += 1 continue changes.append( AuthzManagedRuleChange( managed_rule_id=managed_rule_id, change="updated", - previous_principal_type=current_entry[0], - desired_principal_type=desired_entry[0], - previous_rule_sha256=_authz_rule_sha256(current_entry[1]), - desired_rule_sha256=_authz_rule_sha256(desired_entry[1]), + previous_principal_type=current_entry.principal_type, + desired_principal_type=desired_change_entry.principal_type, + previous_rule_sha256=_authz_rule_sha256(current_entry.rule), + desired_rule_sha256=_authz_rule_sha256(desired_change_entry.rule), ) ) return ( @@ -1339,16 +1420,11 @@ def plan_managed_authz_policy_reconcile( managed_set_id=request.managed_set_id, unmanaged_adoption=request.unmanaged_adoption, ) + policy_safety_blockers: tuple[AuthzManagedPolicySafetyBlocker, ...] = () if _authz_policy_retains_administration( base_policy ) and not _authz_policy_retains_administration(updated_policy): - raise AuthzPolicySafetyError( - code="authz_policy_admin_unreachable", - message=( - "Managed authz policy reconciliation must retain at least one reachable principal " - "that can administer Launchplane authz policy." - ), - ) + policy_safety_blockers = (_managed_policy_safety_blocker("authz_policy_admin_unreachable"),) desired_policy_sha256 = authz_policy_sha256(updated_policy) changed = current_record.policy_sha256 != desired_policy_sha256 desired_set_payload = _desired_managed_set_payload(request.desired_policy) @@ -1357,7 +1433,7 @@ def plan_managed_authz_policy_reconcile( desired_set_payload, sort_keys=True, separators=(",", ":"), - ).encode("utf-8") + ).encode() ).hexdigest() candidate_revision = current_record.revision + int(changed) plan_payload = { @@ -1376,7 +1452,7 @@ def plan_managed_authz_policy_reconcile( "related_issue": request.related_issue, } plan_sha256 = hashlib.sha256( - json.dumps(plan_payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + json.dumps(plan_payload, sort_keys=True, separators=(",", ":")).encode() ).hexdigest() if request.mode == "apply" and request.reviewed_plan_sha256 != plan_sha256: raise AuthzPolicyConflictError( @@ -1410,6 +1486,8 @@ def plan_managed_authz_policy_reconcile( unmanaged_compatibility_candidate_count=unmanaged_compatibility_candidate_count, retired_unmanaged_compatibility_rule_count=len(compatibility_retirements), retired_unmanaged_compatibility_rules=compatibility_retirements, + policy_safety_blocker_count=len(policy_safety_blockers), + policy_safety_blockers=policy_safety_blockers, operational_readiness_blocked_rule_count=len(operational_readiness_blockers), operational_readiness_blockers=operational_readiness_blockers, changes=changes, @@ -1467,7 +1545,7 @@ def _dry_run_authz_policy_record( def authz_managed_policy_reconcile_audit_payload( *, request: AuthzManagedPolicyReconcileEnvelope, - identity: LaunchplaneIdentity, + identity: AuthzApplyingIdentity, previous_record: LaunchplaneAuthzPolicyRecord, new_record: LaunchplaneAuthzPolicyRecord | None, diff: AuthzManagedPolicyDiff, @@ -1507,7 +1585,7 @@ def execute_managed_authz_policy_reconcile( *, record_store: AuthzPolicyRecordStore, request: AuthzManagedPolicyReconcileEnvelope, - identity: LaunchplaneIdentity, + identity: AuthzApplyingIdentity, trace_id: str, now_timestamp: TimestampProvider, authorized_policy_sha256: str = "", @@ -1529,19 +1607,40 @@ def execute_managed_authz_policy_reconcile( current_record=current_record, expected_policy_sha256=authorized_policy_sha256, ) + policy_safety_blockers = list(managed_diff.policy_safety_blockers) if managed_diff.changed and not updated_policy.allows( identity=identity, action=_AUTHZ_POLICY_ADMIN_ACTION, product=request.product, context="launchplane", ): - raise AuthzPolicySafetyError( - code="authz_policy_applying_admin_removed", - message=( - "Managed authz policy reconciliation must retain policy administration " - "authority for the applying identity." - ), + policy_safety_blockers.append( + _managed_policy_safety_blocker("authz_policy_applying_admin_removed") + ) + if managed_diff.changed and not _authz_policy_retains_independent_administration( + policy=updated_policy, + applying_identity=identity, + ): + policy_safety_blockers.append( + _managed_policy_safety_blocker("authz_policy_independent_admin_unreachable") ) + managed_diff = managed_diff.model_copy( + update={ + "policy_safety_blocker_count": len(policy_safety_blockers), + "policy_safety_blockers": tuple(policy_safety_blockers), + } + ) + if request.mode == "apply" and managed_diff.changed: + blockers_by_code = { + blocker.code: blocker for blocker in managed_diff.policy_safety_blockers + } + for code in ( + "authz_policy_admin_unreachable", + "authz_policy_applying_admin_removed", + ): + blocker = blockers_by_code.get(code) + if blocker is not None: + raise AuthzPolicySafetyError(code=blocker.code, message=blocker.message) if ( request.mode == "apply" and managed_diff.changed @@ -1554,21 +1653,20 @@ def execute_managed_authz_policy_reconcile( "blockers remain. Review the dry-run evidence and submit an exact candidate." ), ) - if ( - request.mode == "apply" - and managed_diff.changed - and not _authz_policy_retains_independent_administration( - policy=updated_policy, - applying_identity=identity, - ) - ): - raise AuthzPolicySafetyError( - code="authz_policy_independent_admin_unreachable", - message=( - "Managed authz policy reconciliation must retain a reachable policy " - "administrator independent from the applying identity." + if request.mode == "apply" and managed_diff.changed: + independent_admin_blocker = next( + ( + blocker + for blocker in managed_diff.policy_safety_blockers + if blocker.code == "authz_policy_independent_admin_unreachable" ), + None, ) + if independent_admin_blocker is not None: + raise AuthzPolicySafetyError( + code=independent_admin_blocker.code, + message=independent_admin_blocker.message, + ) diff = managed_diff.model_dump(mode="json") audit = authz_managed_policy_reconcile_audit_payload( request=request, diff --git a/control_plane/service_human_auth.py b/control_plane/service_human_auth.py index 538df7cc1..d404c6289 100644 --- a/control_plane/service_human_auth.py +++ b/control_plane/service_human_auth.py @@ -6,22 +6,14 @@ import base64 import hashlib import hmac +from importlib import import_module import os import secrets from threading import RLock import warnings -from typing import TYPE_CHECKING, Any, Literal, Protocol +from typing import Any, Literal, Protocol, cast from urllib.parse import urlsplit -from cryptography.hazmat.primitives import hashes, hmac as cryptography_hmac - -if TYPE_CHECKING: - from authlib.integrations.requests_client import ( # type: ignore[import-untyped] - OAuth2Session as OAuth2SessionType, - ) -else: - OAuth2SessionType = Any - from control_plane.service_auth import GitHubHumanIdentity, LaunchplaneAuthzPolicy @@ -87,6 +79,18 @@ def write_session_if_csrf_generation( ) -> bool: ... +class OAuthResponse(Protocol): + def json(self) -> Any: ... + + +class OAuth2SessionType(Protocol): + def create_authorization_url(self, url: str, **kwargs: object) -> tuple[str, str]: ... + + def fetch_token(self, url: str, **kwargs: object) -> object: ... + + def get(self, url: str) -> OAuthResponse: ... + + class InMemoryHumanSessionStore: def __init__(self) -> None: self._sessions: dict[str, LaunchplaneHumanSession] = {} @@ -194,13 +198,16 @@ def _new_session( ) -> OAuth2SessionType: with warnings.catch_warnings(): warnings.simplefilter("ignore") - from authlib.integrations.requests_client import OAuth2Session - - return OAuth2Session( - client_id=client_id, - client_secret=client_secret, - scope=scope, - redirect_uri=redirect_uri, + requests_client = import_module("authlib.integrations.requests_client") + + return cast( + OAuth2SessionType, + requests_client.OAuth2Session( + client_id=client_id, + client_secret=client_secret, + scope=scope, + redirect_uri=redirect_uri, + ), ) def authorization_url(self, *, state: str, code_challenge: str) -> str: @@ -259,7 +266,7 @@ def fetch_identity( teams=teams, ) bootstrap_admin_email = "" - if role is None and not authz_policy.github_humans: + if role is None and not _has_db_backed_human_policy_administrator(authz_policy): bootstrap_admin_email = next( iter(sorted(self._config.bootstrap_admin_emails.intersection(email_candidates))), "", @@ -401,9 +408,9 @@ def read_cookie(self, cookie_header: str) -> LaunchplaneHumanSession | None: def authorization_claims_are_current(self, session: LaunchplaneHumanSession) -> bool: now = self._now() return ( - session.created_at <= now - and session.created_at + timedelta(seconds=SESSION_AUTHORIZATION_CLAIMS_TTL_SECONDS) - > now + session.created_at + <= now + < session.created_at + timedelta(seconds=SESSION_AUTHORIZATION_CLAIMS_TTL_SECONDS) ) def revoke(self, session: LaunchplaneHumanSession) -> None: @@ -451,6 +458,8 @@ def consume_csrf_token( token: str, ) -> LaunchplaneHumanSession | None: normalized_token = token.strip() + if not normalized_token.isascii(): + return None generation = _csrf_token_generation(normalized_token) if generation is None or generation != session.csrf_generation: return None @@ -491,30 +500,37 @@ def clear_cookie_header(self) -> str: def _sign_cookie_value(self, session_id: str) -> str: normalized_session_id = session_id.strip() - signer = cryptography_hmac.HMAC( - self._config.session_secret.encode("utf-8"), - hashes.SHA256(), - ) - signer.update(normalized_session_id.encode("utf-8")) - signature = signer.finalize().hex() + signature = hmac.new( + self._config.session_secret.encode(), + normalized_session_id.encode(), + hashlib.sha256, + ).hexdigest() return f"{normalized_session_id}.{signature}" def _csrf_signature(self, *, session_id: str, generation: int) -> str: - signer = cryptography_hmac.HMAC( - self._config.session_secret.encode("utf-8"), - hashes.SHA256(), + signature_payload = b"\0".join( + ( + b"launchplane-browser-csrf-v1", + session_id.encode(), + str(generation).encode(), + ) ) - signer.update(b"launchplane-browser-csrf-v1\0") - signer.update(session_id.encode("utf-8")) - signer.update(b"\0") - signer.update(str(generation).encode("ascii")) - return base64.urlsafe_b64encode(signer.finalize()).decode("ascii").rstrip("=") + signature = hmac.new( + self._config.session_secret.encode(), + signature_payload, + hashlib.sha256, + ).digest() + return base64.urlsafe_b64encode(signature).decode().rstrip("=") def _verify_cookie_value(self, cookie_session_id: str) -> str: session_id, separator, signature = cookie_session_id.strip().partition(".") if not separator or not signature: return "" - if not session_id or any(character.isspace() for character in session_id): + if ( + not session_id + or not signature.isascii() + or any(character.isspace() for character in session_id) + ): return "" expected_cookie_value = self._sign_cookie_value(session_id) _expected_session_id, _separator, expected_signature = expected_cookie_value.partition(".") @@ -545,7 +561,9 @@ def browser_origin_from_url(value: str) -> str: if ":" in host: host = f"[{host}]" default_port = 443 if scheme == "https" else 80 - port_suffix = f":{port}" if port is not None and port != default_port else "" + port_suffix = "" + if port is not None and port != default_port: + port_suffix = f":{int(port)}" return f"{scheme}://{host}{port_suffix}" diff --git a/docs/operations.md b/docs/operations.md index 82702a75d..e6b9ff1c3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -675,6 +675,10 @@ the reconciliation request. Every standalone selector binds one exact `operator.*` identity. The deploy workflow is not an authorization wrapper and does not carry a managed-set secret. +Land reusable-workflow input compatibility before advancing any immutable +wrapper pin that depends on it. Never pass a newly introduced input to an older +pinned worker revision that does not declare that input. + ### Reusable workflow SHA rollout for readiness-gated actions Operational-readiness actions require one exact managed rule for the current @@ -704,6 +708,14 @@ rules; an unchanged replay remains a no-op. Every expansion and contraction still requires the protected human approval; diagnostics do not bypass or weaken that control. +Managed-authz dry-runs also return `policy_safety_blockers` for candidates that +would remove the last reachable policy administrator, remove the applying +administrator, or leave no administrator independent from the applying +identity. These blockers are review evidence rather than dry-run transport +errors. Apply remains fail-closed with the corresponding bounded error code and +does not persist the candidate while any applicable policy-safety blocker +remains. + `Tracked Target Logs` and `Odoo Website Bootstrap Override` follow this same two-change rollout. Their dispatch files are thin operator entrypoints pinned to reviewed reusable workers at a full commit SHA; production authorization binds diff --git a/tests/test_authz_grant_service.py b/tests/test_authz_grant_service.py index 99e50c1dd..d39d15d9b 100644 --- a/tests/test_authz_grant_service.py +++ b/tests/test_authz_grant_service.py @@ -110,6 +110,34 @@ def _active_record_for_policy(policy: LaunchplaneAuthzPolicy) -> LaunchplaneAuth ) +def _managed_service_deploy_request() -> AuthzManagedPolicyReconcileEnvelope: + return AuthzManagedPolicyReconcileEnvelope.model_validate( + { + "schema_version": 2, + "product": "launchplane", + "mode": "dry_run", + "managed_set_id": "operator.launchplane", + "schema_migration": "migrate_v1_to_v2", + "unmanaged_adoption": "adopt_matching", + "desired_policy": { + "schema_version": 2, + "github_actions": [ + { + "managed_set_id": "operator.launchplane", + "managed_rule_id": "service.deploy", + "repository": "cbusillo/launchplane", + "repository_id": "1001", + "repository_owner_id": "2001", + "products": ["launchplane"], + "contexts": ["launchplane"], + "actions": ["launchplane_service_deploy.execute"], + } + ], + }, + } + ) + + def _authz_rollout_fixture(filename: str) -> LaunchplaneAuthzPolicy: fixture_path = Path(__file__).parent / "fixtures" / "authz" / filename return LaunchplaneAuthzPolicy.model_validate( @@ -345,31 +373,7 @@ def test_context_ingress_managed_rule_preserves_legacy_unpinned_scope(self) -> N def test_managed_route_rejects_policy_change_after_authorization(self) -> None: active_record = _active_record() - request = AuthzManagedPolicyReconcileEnvelope.model_validate( - { - "schema_version": 2, - "product": "launchplane", - "mode": "dry_run", - "managed_set_id": "operator.launchplane", - "schema_migration": "migrate_v1_to_v2", - "unmanaged_adoption": "adopt_matching", - "desired_policy": { - "schema_version": 2, - "github_actions": [ - { - "managed_set_id": "operator.launchplane", - "managed_rule_id": "service.deploy", - "repository": "cbusillo/launchplane", - "repository_id": "1001", - "repository_owner_id": "2001", - "products": ["launchplane"], - "contexts": ["launchplane"], - "actions": ["launchplane_service_deploy.execute"], - } - ], - }, - } - ) + request = _managed_service_deploy_request() with self.assertRaisesRegex(AuthzPolicyConflictError, "after the caller was authorized"): execute_managed_authz_policy_reconcile( @@ -383,31 +387,7 @@ def test_managed_route_rejects_policy_change_after_authorization(self) -> None: def test_managed_reconcile_adopts_v1_rule_and_migrates_schema(self) -> None: current_record = _active_record() - request = AuthzManagedPolicyReconcileEnvelope.model_validate( - { - "schema_version": 2, - "product": "launchplane", - "mode": "dry_run", - "managed_set_id": "operator.launchplane", - "schema_migration": "migrate_v1_to_v2", - "unmanaged_adoption": "adopt_matching", - "desired_policy": { - "schema_version": 2, - "github_actions": [ - { - "managed_set_id": "operator.launchplane", - "managed_rule_id": "service.deploy", - "repository": "cbusillo/launchplane", - "repository_id": "1001", - "repository_owner_id": "2001", - "products": ["launchplane"], - "contexts": ["launchplane"], - "actions": ["launchplane_service_deploy.execute"], - } - ], - }, - } - ) + request = _managed_service_deploy_request() current_policy, observed_record, updated_policy, diff = plan_managed_authz_policy_reconcile( record_store=_AuthzPolicyStore((current_record,)), @@ -533,17 +513,42 @@ def test_managed_reconcile_rejects_removing_last_policy_administrator(self) -> N "product": "launchplane", "mode": "dry_run", "managed_set_id": "operator.owner", + "reason": "Verify the last policy administrator cannot be removed.", "desired_policy": {"schema_version": 2}, } ) + _, _, _, diff = plan_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((_active_record_for_policy(current_policy),)), + request=request, + ) + + self.assertEqual(diff.policy_safety_blocker_count, 1) + self.assertEqual( + diff.policy_safety_blockers[0].code, + "authz_policy_admin_unreachable", + ) + self.assertIn( + "retain at least one reachable principal", + diff.policy_safety_blockers[0].message, + ) + apply_request = AuthzManagedPolicyReconcileEnvelope.model_validate( + { + **request.model_dump(mode="json"), + "mode": "apply", + "reviewed_plan_sha256": diff.plan_sha256, + } + ) with self.assertRaises(AuthzPolicySafetyError) as raised: - plan_managed_authz_policy_reconcile( + execute_managed_authz_policy_reconcile( record_store=_AuthzPolicyStore((_active_record_for_policy(current_policy),)), - request=request, + request=apply_request, + identity=_workflow_admin_identity(), + trace_id="trace-last-admin-apply", + now_timestamp=lambda: "2026-08-17T00:00:00Z", + authorized_policy_sha256=authz_policy_sha256(current_policy), ) self.assertEqual(raised.exception.code, "authz_policy_admin_unreachable") - self.assertIn("retain at least one reachable principal", str(raised.exception)) def test_managed_reconcile_counts_terminal_agent_policy_administrator(self) -> None: terminal_admin = TerminalAgentPolicyRule( @@ -565,13 +570,16 @@ def test_managed_reconcile_counts_terminal_agent_policy_administrator(self) -> N desired_policy=LaunchplaneAuthzPolicy(schema_version=2), ) - with self.assertRaises(AuthzPolicySafetyError) as raised: - plan_managed_authz_policy_reconcile( - record_store=_AuthzPolicyStore((current_record,)), - request=request, - ) + _, _, _, diff = plan_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((current_record,)), + request=request, + ) - self.assertEqual(raised.exception.code, "authz_policy_admin_unreachable") + self.assertEqual(diff.policy_safety_blocker_count, 1) + self.assertEqual( + diff.policy_safety_blockers[0].code, + "authz_policy_admin_unreachable", + ) def test_managed_apply_requires_independent_policy_administrator(self) -> None: identity = _workflow_admin_identity() @@ -584,7 +592,7 @@ def test_managed_apply_requires_independent_policy_administrator(self) -> None: ) def apply_request( - current_record: LaunchplaneAuthzPolicyRecord, + policy_record: LaunchplaneAuthzPolicyRecord, ) -> AuthzManagedPolicyReconcileEnvelope: dry_run = AuthzManagedPolicyReconcileEnvelope( schema_version=2, @@ -597,7 +605,7 @@ def apply_request( ), ) _, _, _, diff = plan_managed_authz_policy_reconcile( - record_store=_AuthzPolicyStore((current_record,)), + record_store=_AuthzPolicyStore((policy_record,)), request=dry_run, ) return AuthzManagedPolicyReconcileEnvelope.model_validate( @@ -613,6 +621,29 @@ def apply_request( github_actions=(applying_admin, retired_rule), ) current_record = _active_record_for_policy(current_policy) + dry_run_result = execute_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((current_record,)), + request=AuthzManagedPolicyReconcileEnvelope( + schema_version=2, + product="launchplane", + managed_set_id="operator.launchplane", + reason="Retire the obsolete managed read rule.", + desired_policy=LaunchplaneAuthzPolicy( + schema_version=2, + github_actions=(applying_admin,), + ), + ), + identity=identity, + trace_id="trace-independent-admin-dry-run", + now_timestamp=lambda: "2026-08-17T00:00:00Z", + authorized_policy_sha256=current_record.policy_sha256, + ) + dry_run_diff = cast(dict[str, object], dry_run_result.driver_result["diff"]) + self.assertEqual(dry_run_diff["policy_safety_blocker_count"], 1) + self.assertEqual( + cast(list[dict[str, object]], dry_run_diff["policy_safety_blockers"])[0]["code"], + "authz_policy_independent_admin_unreachable", + ) with self.assertRaises(AuthzPolicySafetyError) as raised: execute_managed_authz_policy_reconcile( record_store=_AuthzPolicyStore((current_record,)), @@ -1254,6 +1285,7 @@ def reconcile_request( "product": "launchplane", "managed_set_id": "operator.launchplane", "unmanaged_adoption": "adopt_matching", + "reason": "Verify stale managed administration fails closed.", "desired_policy": { "schema_version": 2, "github_actions": [managed_rule.model_dump(mode="json")], @@ -1268,15 +1300,37 @@ def reconcile_request( github_actions=(compatibility_rule, stale_managed_rule), ) ) - with self.assertRaises(AuthzPolicySafetyError): + stale_result = execute_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((stale_record,)), + request=reconcile_request(stale_managed_rule), + identity=identity, + trace_id="trace-retirement-lockout-dry-run", + now_timestamp=lambda: "2026-07-20T00:00:00Z", + authorized_policy_sha256=stale_record.policy_sha256, + ) + stale_diff = cast(dict[str, object], stale_result.driver_result["diff"]) + stale_blockers = cast(list[dict[str, object]], stale_diff["policy_safety_blockers"]) + self.assertIn( + "authz_policy_applying_admin_removed", + {blocker["code"] for blocker in stale_blockers}, + ) + stale_apply_request = AuthzManagedPolicyReconcileEnvelope.model_validate( + { + **reconcile_request(stale_managed_rule).model_dump(mode="json"), + "mode": "apply", + "reviewed_plan_sha256": stale_diff["plan_sha256"], + } + ) + with self.assertRaises(AuthzPolicySafetyError) as raised: execute_managed_authz_policy_reconcile( record_store=_AuthzPolicyStore((stale_record,)), - request=reconcile_request(stale_managed_rule), + request=stale_apply_request, identity=identity, trace_id="trace-retirement-lockout", now_timestamp=lambda: "2026-07-20T00:00:00Z", authorized_policy_sha256=stale_record.policy_sha256, ) + self.assertEqual(raised.exception.code, "authz_policy_applying_admin_removed") active_managed_rule = managed_admin_rule(repository_id=identity.repository_id) active_record = _active_record_for_policy( @@ -1326,6 +1380,7 @@ def test_managed_route_requires_every_change_to_retain_applying_admin(self) -> N "schema_version": 2, "product": "launchplane", "managed_set_id": "operator.launchplane", + "reason": "Verify applying administration cannot be removed.", "desired_policy": { "schema_version": 2, "github_actions": [replacement_rule.model_dump(mode="json")], @@ -1333,10 +1388,32 @@ def test_managed_route_requires_every_change_to_retain_applying_admin(self) -> N } ) + dry_run_result = execute_managed_authz_policy_reconcile( + record_store=_AuthzPolicyStore((current_record,)), + request=request, + identity=identity, + trace_id="trace-managed-lockout-dry-run", + now_timestamp=lambda: "2026-08-17T00:00:00Z", + authorized_policy_sha256=current_record.policy_sha256, + ) + dry_run_diff = cast(dict[str, object], dry_run_result.driver_result["diff"]) + dry_run_blockers = cast(list[dict[str, object]], dry_run_diff["policy_safety_blockers"]) + self.assertIn( + "authz_policy_applying_admin_removed", + {blocker["code"] for blocker in dry_run_blockers}, + ) + apply_request = AuthzManagedPolicyReconcileEnvelope.model_validate( + { + **request.model_dump(mode="json"), + "mode": "apply", + "reviewed_plan_sha256": dry_run_diff["plan_sha256"], + } + ) + with self.assertRaises(AuthzPolicySafetyError) as raised: execute_managed_authz_policy_reconcile( record_store=_AuthzPolicyStore((current_record,)), - request=request, + request=apply_request, identity=identity, trace_id="trace-managed-lockout", now_timestamp=lambda: "2026-08-17T00:00:00Z", diff --git a/tests/test_generic_web_preview_authz.py b/tests/test_generic_web_preview_authz.py index ebc76abd1..657b8690a 100644 --- a/tests/test_generic_web_preview_authz.py +++ b/tests/test_generic_web_preview_authz.py @@ -112,8 +112,7 @@ def test_operator_workflow_exposes_scoped_ingress_expansion(self) -> None: apply_workflow_text = Path( ".github/workflows/reusable-generic-web-preview-authz-apply.yml" ).read_text(encoding="utf-8") - self.assertIn("group: launchplane-authz-policy", apply_workflow_text) - self.assertIn("cancel-in-progress: false", apply_workflow_text) + self.assertNotIn("group: launchplane-authz-policy", apply_workflow_text) self.assertIn( 'managed_set_id" != "operator.generic-web-preview"', apply_workflow_text, diff --git a/tests/test_github_human_policy_rule.py b/tests/test_github_human_policy_rule.py new file mode 100644 index 000000000..6e95ecb97 --- /dev/null +++ b/tests/test_github_human_policy_rule.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from dataclasses import replace +import unittest + +from control_plane.service_auth import GitHubHumanIdentity, GitHubHumanPolicyRule + + +def _identity() -> GitHubHumanIdentity: + return GitHubHumanIdentity( + login="alice", + github_id=123, + name="Alice Example", + email="alice@example.com", + organizations=frozenset({"cbusillo"}), + teams=frozenset({"platform", "cbusillo/platform"}), + role="admin", + ) + + +class GitHubHumanPolicyRuleTests(unittest.TestCase): + def test_every_configured_principal_selector_must_match(self) -> None: + identity = _identity() + rule = GitHubHumanPolicyRule( + github_ids=(identity.github_id,), + logins=(identity.login,), + organizations=("cbusillo",), + teams=("cbusillo/platform",), + roles=("admin",), + products=("launchplane",), + contexts=("launchplane",), + actions=("authz_policy_grant.write",), + ) + + self.assertTrue( + rule.matches_principal( + github_id=identity.github_id, + login=identity.login, + organizations=identity.organizations, + teams=identity.teams, + role=identity.role, + ) + ) + self.assertTrue( + rule.allows( + identity=identity, + action="authz_policy_grant.write", + product="launchplane", + context="launchplane", + ) + ) + + mismatched_identities = ( + replace(identity, github_id=999), + replace(identity, login="mallory"), + replace(identity, organizations=frozenset({"another-org"})), + replace(identity, teams=frozenset({"cbusillo/another-team"})), + replace(identity, role="read_only"), + ) + for mismatched_identity in mismatched_identities: + with self.subTest(identity=mismatched_identity): + self.assertFalse( + rule.matches_principal( + github_id=mismatched_identity.github_id, + login=mismatched_identity.login, + organizations=mismatched_identity.organizations, + teams=mismatched_identity.teams, + role=mismatched_identity.role, + ) + ) + + def test_resource_and_action_scope_remain_separate_from_principal_match(self) -> None: + identity = _identity() + rule = GitHubHumanPolicyRule( + github_ids=(identity.github_id,), + products=("launchplane",), + contexts=("launchplane",), + actions=("authz_policy_grant.write",), + ) + + for action, product, context in ( + ("product_environment.read", "launchplane", "launchplane"), + ("authz_policy_grant.write", "another-product", "launchplane"), + ("authz_policy_grant.write", "launchplane", "another-context"), + ): + with self.subTest(action=action, product=product, context=context): + self.assertTrue( + rule.matches_principal( + github_id=identity.github_id, + login=identity.login, + organizations=identity.organizations, + teams=identity.teams, + role=identity.role, + ) + ) + self.assertFalse( + rule.allows( + identity=identity, + action=action, + product=product, + context=context, + ) + ) diff --git a/tests/test_service_human_auth.py b/tests/test_service_human_auth.py index a2fe7737a..abe1d235d 100644 --- a/tests/test_service_human_auth.py +++ b/tests/test_service_human_auth.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta, timezone import unittest +from unittest.mock import patch from control_plane.service_auth import ( GitHubHumanIdentity, @@ -9,6 +10,11 @@ LaunchplaneAuthzPolicy, ) from control_plane.service_human_auth import ( + GITHUB_EMAILS_URL, + GITHUB_ORGS_URL, + GITHUB_TEAMS_URL, + GITHUB_USER_URL, + GitHubOAuthClient, GitHubOAuthConfig, HumanSessionManager, InMemoryHumanSessionStore, @@ -18,6 +24,26 @@ ) +class _FakeGitHubResponse: + def __init__(self, payload: object) -> None: + self._payload = payload + + def json(self) -> object: + return self._payload + + +class _FakeOAuthSession: + def __init__(self, payloads: dict[str, object]) -> None: + self._payloads = payloads + self.token_fetched = False + + def fetch_token(self, *_args: object, **_kwargs: object) -> None: + self.token_fetched = True + + def get(self, url: str) -> _FakeGitHubResponse: + return _FakeGitHubResponse(self._payloads[url]) + + def _config(*, session_secret: str = "session-secret") -> GitHubOAuthConfig: return GitHubOAuthConfig( client_id="client-id", @@ -40,6 +66,32 @@ def _identity() -> GitHubHumanIdentity: ) +def _oauth_config() -> GitHubOAuthConfig: + return GitHubOAuthConfig( + client_id="client-id", + client_secret="client-secret", + public_url="https://launchplane.example", + session_secret="session-secret", + bootstrap_admin_emails=frozenset({"alice@example.com"}), + ) + + +def _oauth_session() -> _FakeOAuthSession: + return _FakeOAuthSession( + { + GITHUB_USER_URL: { + "login": "alice", + "id": 123, + "name": "Alice Example", + "email": "alice@example.com", + }, + GITHUB_ORGS_URL: [], + GITHUB_TEAMS_URL: [], + GITHUB_EMAILS_URL: [], + } + ) + + class HumanSessionManagerTests(unittest.TestCase): def test_bootstrap_admin_role_applies_before_db_human_policy_exists(self) -> None: manager = HumanSessionManager( @@ -110,6 +162,50 @@ def test_legacy_human_policy_does_not_disable_bootstrap_admin(self) -> None: manager.authorized_role(identity=_identity(), authz_policy=policy), "admin" ) + def test_oauth_legacy_human_policy_preserves_bootstrap_admin(self) -> None: + oauth_session = _oauth_session() + policy = LaunchplaneAuthzPolicy( + github_humans=( + GitHubHumanPolicyRule( + logins=("*",), + actions=("authz_policy_grant.write",), + ), + ) + ) + + with patch.object(GitHubOAuthClient, "_new_session", return_value=oauth_session): + identity = GitHubOAuthClient(_oauth_config()).fetch_identity( + code="github-code", + code_verifier="verifier", + authz_policy=policy, + ) + + self.assertEqual(identity.role, "admin") + + def test_oauth_db_policy_admin_retires_bootstrap_admin(self) -> None: + oauth_session = _oauth_session() + policy = LaunchplaneAuthzPolicy( + github_humans=( + GitHubHumanPolicyRule( + github_ids=(999,), + roles=("admin",), + products=("launchplane",), + contexts=("launchplane",), + actions=("authz_policy_grant.write",), + ), + ) + ) + + with ( + patch.object(GitHubOAuthClient, "_new_session", return_value=oauth_session), + self.assertRaises(PermissionError), + ): + GitHubOAuthClient(_oauth_config()).fetch_identity( + code="github-code", + code_verifier="verifier", + authz_policy=policy, + ) + def test_session_cookie_is_signed_and_round_trips(self) -> None: store = InMemoryHumanSessionStore() manager = HumanSessionManager(config=_config(), session_store=store) @@ -157,6 +253,7 @@ def test_session_cookie_rejects_unsigned_or_malformed_values(self) -> None: self.assertIsNone(manager.read_cookie(f"launchplane_session={session.session_id}")) self.assertIsNone(manager.read_cookie("launchplane_session=bad value.signature")) + self.assertIsNone(manager.read_cookie(f"launchplane_session={session.session_id}.é")) self.assertIsNone(manager.read_cookie("other=value")) def test_delete_cookie_session_requires_valid_signature(self) -> None: @@ -232,6 +329,7 @@ def test_csrf_token_rejects_other_session_and_stale_generation(self) -> None: first_token = manager.csrf_token(first_session) self.assertIsNone(manager.consume_csrf_token(second_session, first_token)) + self.assertIsNone(manager.consume_csrf_token(first_session, "v1.0.é")) rotated_session = manager.consume_csrf_token(first_session, first_token) assert rotated_session is not None self.assertIsNone(manager.consume_csrf_token(rotated_session, first_token))