Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .github/workflows/grant-paper-executor-permissions.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
name: Grant fixed PAPER executor IAM permissions

on:
workflow_dispatch:
inputs:
mode:
description: Dry-run first; apply needs a separate exact-revision run card
type: choice
options: [dry-run, apply]
default: dry-run
required: true
approved_revision:
description: Exact merged main SHA approved by the separate run card
type: string
required: true
owner_run_card:
description: Separate owner run-card reference; Environment reviewer verifies scope
type: string
required: true
confirm:
description: Must be GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS
type: string
required: true

permissions:
contents: read

# Serialize with the existing scheduler IAM route; never cancel an IAM writer.
concurrency:
group: paper-scheduler-jobs-get-iam
cancel-in-progress: false

jobs:
fixed-grant:
if: >-
github.event_name == 'workflow_dispatch'
&& github.repository == 'QuantStrategyLab/LongBridgePlatform'
&& github.ref == 'refs/heads/main'
&& github.run_attempt == 1
&& inputs.approved_revision == github.sha
&& inputs.confirm == 'GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS'
&& (inputs.mode == 'dry-run' || inputs.mode == 'apply')
environment: longbridge-paper
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
id-token: write
env:
IAM_MODE: ${{ inputs.mode }}
IAM_CONFIRM: ${{ inputs.confirm }}
IAM_APPROVED_REVISION: ${{ inputs.approved_revision }}
IAM_OWNER_RUN_CARD: ${{ inputs.owner_run_card }}
steps:
# Read mappings inside the action, never through logged inputs or env.
# Missing variable-read authority blocks before authentication or writes.
- name: Mask protected mappings before exposing step environment
uses: actions/github-script@v8
with:
script: |
const names = [
'GCP_PROJECT_ID', 'PAPER_SCHEDULER_GET_PROJECT_ID',
'GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT', 'PAPER_IAM_GRANT_RECIPIENT',
'PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT', 'PAPER_IAM_BOOTSTRAP_WIF_PROVIDER'
];
const mappings = [];
try {
for (const name of names) {
let data;
try {
({data} = await github.request(
'GET /repos/{owner}/{repo}/environments/{environment_name}/variables/{name}',
{...context.repo, environment_name: 'longbridge-paper', name}));
} catch (error) {
if (error.status !== 404) throw error;
({data} = await github.request(
'GET /repos/{owner}/{repo}/actions/variables/{name}',
{...context.repo, name}));
}
if (typeof data.value !== 'string' || !data.value.trim()) {
throw new Error('Missing mapping');
}
core.setSecret(data.value);
mappings.push([name, data.value]);
}
} catch {
throw new Error('Protected mapping read failed; stop before authentication');
}
for (const [name, value] of mappings) core.exportVariable(name, value);
- name: Verify Environment approval protection remains configured
uses: actions/github-script@v8
with:
script: |
let data;
try {
({data} = await github.rest.repos.getEnvironment({
...context.repo, environment_name: 'longbridge-paper'
}));
} catch {
throw new Error('Environment protection read failed');
}
const approval = data.protection_rules?.find(r => r.type === 'required_reviewers');
if (!approval || !approval.reviewers || approval.reviewers.length < 1) {
throw new Error('Required Environment approval protection is missing');
}
- name: Checkout exact protected revision
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Validate fixed recipient and bootstrap mappings before authentication
run: |
python3 - <<'PY'
import os
from scripts.paper_executor_grant import identity
identity(os.environ)
if not os.environ.get('PAPER_IAM_BOOTSTRAP_WIF_PROVIDER'):
raise SystemExit('BLOCKED: bootstrap WIF mapping missing')
PY
- name: Authenticate existing authorized bootstrap identity
uses: google-github-actions/auth@v3
with:
workload_identity_provider: ${{ env.PAPER_IAM_BOOTSTRAP_WIF_PROVIDER }}
service_account: ${{ env.PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT }}
- name: Set up cloud client
uses: google-github-actions/setup-gcloud@v3
with:
project_id: ${{ env.GCP_PROJECT_ID }}
- name: Read plan and apply only the fixed delta when separately authorized
run: |
set -euo pipefail
IAM_MODE=dry-run python3 -m scripts.paper_executor_grant
if [ "$IAM_MODE" = apply ]; then
python3 -m scripts.paper_executor_grant
fi
81 changes: 81 additions & 0 deletions docs/iam/paper-scheduler-get.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,84 @@ successful later operations. The original role GET, accepted 404, policy checks,
CAS, dry-run default and apply gates remain required. A later executor grant,
especially project `setIamPolicy`, needs a separate owner decision. Publishing
this diagnostic does not authorize its execution or any IAM change.


## Protected executor bootstrap definition

The [fixed executor grant workflow](../../.github/workflows/grant-paper-executor-permissions.yml)
addresses the executor-permission blocker in the scheduler IAM route. It creates
one project custom role containing exactly `iam.roles.get`,
`resourcemanager.projects.getIamPolicy`, `iam.roles.create`, and
`resourcemanager.projects.setIamPolicy`, and binds it only to the existing
`apply-paper-scheduler-jobs-get` executor. These are project-level IAM powers;
this grant does not restrict what a compromised executor could do with those
permissions. The fixed script restricts the intended operation, while the
Environment approval and WIF trust remain essential access controls.

Publishing or merging this definition does not execute or authorize a grant.
The definition retains the four-permission scope of approval t1319s7706 and the
scheduler workflow lineage from PR #719; t1320s7720 authorizes this Draft only.
After merge, a separate owner run card must bind the merged main revision,
PAPER project, existing recipient, bootstrap identity, and grant scope. The
Environment reviewer must verify that card and a successful dry-run plan before
approving apply. The workflow also runs a read-only plan immediately before an
apply attempt. No scheduler apply, PAPER start, secret creation, deployment or
trading-switch change is part of this workflow.

Use the existing `longbridge-paper` Environment. It must have at least one
required reviewer and protect main; the workflow rejects a missing or unreadable
reviewer rule before cloud authentication. Current Environment policy is an
operational prerequisite, not established by committing this file. Maintainers
must ensure WIF trust limits the bootstrap to this repository, protected main
and Environment, and prevent unreviewed callers from assuming it.

Protected Environment mappings, never literal identifiers in Git:

| Variable | Required meaning |
| --- | --- |
| `GCP_PROJECT_ID` | Approved PAPER project |
| `PAPER_SCHEDULER_GET_PROJECT_ID` | Same approved project |
| `GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT` | Existing scheduler IAM executor |
| `PAPER_IAM_GRANT_RECIPIENT` | Independent approved mapping to that same executor |
| `PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT` | Existing authorized bootstrap service account in the same project, distinct from recipient |
| `PAPER_IAM_BOOTSTRAP_WIF_PROVIDER` | Existing authorized WIF provider for that bootstrap |

No new production principal is proposed or created. A compatible bootstrap
identity, its effective authority and these additional protected mappings have
not been proved by offline tests. Missing mappings block execution; configuration
and WIF changes need their own authorization. Never substitute a broad new
administrator grant or reuse the known deficient executor as bootstrap.

Dispatch accepts only `dry-run` or `apply`, an exact merged-main revision,
`owner_run_card` reference and `GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS`
confirmation. Text input is an audit reference, not proof of owner authorization;
the required Environment reviewer validates it. Workflow reruns are rejected.
No grant dispatch is part of the Draft preparation job.

The script checks bootstrap permissions, reads role and policy, rejects role or
binding collisions, and re-reads before writes. It shares the scheduler IAM
workflow concurrency group, uses policy etag compare-and-set, changes only
bindings, and verifies the exact additive delta after writing. Transport errors,
unknown state and concurrent changes exit nonzero without write retries. Role
creation and policy binding are separate operations: partial or ambiguous
outcomes require readback and a new scoped run card before another attempt.
An existing exact role and binding are idempotent across process restarts.
Only fixed status messages are emitted; raw cloud responses stay in memory.


### Grant mapping redaction prerequisite

The fixed executor grant masks mappings in its first action before exporting
any mapping to later step environments or authentication inputs. It reads the
six named variables from the protected Environment, falling back to repository
variables only for an absent Environment variable (HTTP 404). Organization-only
variables are not a fallback. All mappings must be readable and nonempty;
permission, transport or malformed-response failures stop before authentication.
No GitHub token permission or Environment configuration is changed by this
workflow definition. Variable API access by the workflow token is a separate
prerequisite and is not established by local tests; an inaccessible mapping
blocks the route and does not authorize provisioning a token or changing access.

Role permission responses are checked for exact membership and cardinality,
independent of order. Fresh role snapshots normalize permission ordering while
retaining etag and every other field for concurrent-drift detection.
140 changes: 140 additions & 0 deletions scripts/paper_executor_grant.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Fixed bootstrap grant for the existing PAPER scheduler IAM executor.

No remote diagnostics or identifiers are emitted. Uses the scheduler IAM
transport and policy validator; never retries a write or changes runtime state.
"""
import copy
import os
import re

from scripts.paper_scheduler_get_iam import (
EXECUTOR_PERMISSIONS,
Blocked,
Cloud,
validate_policy,
)

PERMISSIONS = EXECUTOR_PERMISSIONS
ROLE_ID = "paperSchedulerIamExecutor"
DEFINITION = {
"title": "PAPER scheduler IAM executor",
"description": "Fixed four-permission PAPER scheduler IAM executor grant",
"stage": "GA", "includedPermissions": list(PERMISSIONS),
}


def identity(env):
required = {
"GITHUB_EVENT_NAME": "workflow_dispatch",
"GITHUB_REPOSITORY": "QuantStrategyLab/LongBridgePlatform",
"GITHUB_REF": "refs/heads/main", "GITHUB_RUN_ATTEMPT": "1",
"IAM_CONFIRM": "GRANT_PAPER_EXECUTOR_FOUR_PERMISSIONS",
}
project = env.get("GCP_PROJECT_ID", "")
recipient = env.get("PAPER_IAM_GRANT_RECIPIENT", "")
bootstrap = env.get("PAPER_IAM_BOOTSTRAP_SERVICE_ACCOUNT", "")
sa_pattern = r"[a-z][a-z0-9-]{4,28}[a-z0-9]@" + re.escape(project) + r"\.iam\.gserviceaccount\.com"
revision = env.get("IAM_APPROVED_REVISION", "")
if (any(env.get(k) != v for k, v in required.items())
or env.get("IAM_MODE") not in {"dry-run", "apply"}
or not re.fullmatch(r"[a-z][a-z0-9-]{4,28}[a-z0-9]", project)
or project != env.get("PAPER_SCHEDULER_GET_PROJECT_ID")
or recipient != env.get("GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT")
or not re.fullmatch(sa_pattern, recipient)
or not re.fullmatch(sa_pattern, bootstrap) or bootstrap == recipient
or not re.fullmatch(r"[0-9a-f]{40}", revision)
or revision != env.get("GITHUB_SHA")
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", env.get("IAM_OWNER_RUN_CARD", ""))):
raise Blocked("authority_mapping_rejected")
return project, f"serviceAccount:{recipient}"


def validate_role(role, name):
if (not isinstance(role, dict) or role.get("name") != name
or not isinstance(role.get("includedPermissions"), list)
or len(role["includedPermissions"]) != len(PERMISSIONS)
or any(type(p) is not str for p in role["includedPermissions"])
or set(role["includedPermissions"]) != set(PERMISSIONS)
or role.get("deleted", False) is not False or role.get("stage") != "GA"
or not isinstance(role.get("etag"), str) or not role["etag"]):
raise Blocked("role_collision_or_integrity_failure")


def execute(env, request):
project, member = identity(env)
root = f"https://cloudresourcemanager.googleapis.com/v1/projects/{project}"
name = f"projects/{project}/roles/{ROLE_ID}"
role_url = f"https://iam.googleapis.com/v1/{name}"
held = request("POST", root + ":testIamPermissions", {"permissions": list(PERMISSIONS)})
if (not isinstance(held, dict) or set(held) != {"permissions"}
or not isinstance(held["permissions"], list)
or len(held["permissions"]) != len(PERMISSIONS)
or any(type(p) is not str for p in held["permissions"])
or set(held["permissions"]) != set(PERMISSIONS)):
raise Blocked("bootstrap_permissions_missing_or_unknown")

def read_role():
role = request("GET", role_url, missing=True)
if role is not None:
validate_role(role, name)
# IAM does not promise ordering; compare fresh snapshots canonically.
role = {**role, "includedPermissions": sorted(role["includedPermissions"])}
return role

def read_policy():
policy = request("POST", root + ":getIamPolicy", {"options": {"requestedPolicyVersion": 3}})
validate_policy(policy, name, member)
return policy

role = read_role()
before = read_policy()
bound = validate_policy(before, name, member)
if role is None and bound:
raise Blocked("orphan_binding_collision")
print("plan: fixed four-permission role; existing PAPER executor only")
if env["IAM_MODE"] == "dry-run":
print("dry-run: zero writes; grant NOT_EXECUTED")
return
# Fresh observations detect another process/writer before the first write.
if read_role() != role or read_policy() != before:
raise Blocked("concurrent_drift")
if role is None:
request("POST", f"https://iam.googleapis.com/v1/projects/{project}/roles",
{"roleId": ROLE_ID, "role": copy.deepcopy(DEFINITION)})
role = read_role()
if role is None:
raise Blocked("created_role_missing")
# Creation and binding are not atomic. Stop on partial/unknown outcomes;
# never roll back or retry. A new run card must inspect any partial result.
fresh = read_policy()
if fresh != before or read_role() != role:
raise Blocked("concurrent_drift")
expected = copy.deepcopy(before)
if not bound:
expected["bindings"].append({"role": name, "members": [member]})
request("POST", root + ":setIamPolicy", {
"policy": {k: expected[k] for k in ("version", "etag", "bindings")},
"updateMask": "bindings,etag",
})
after = read_policy()
if (read_role() != role
or {k: v for k, v in after.items() if k != "etag"}
!= {k: v for k, v in expected.items() if k != "etag"}
or not validate_policy(after, name, member)):
raise Blocked("after_readback_drift")
print("verified: exact role and additive binding; no runtime action")


def main():
try:
identity(os.environ)
execute(os.environ, Cloud())
return 0
except Exception:
# Shared transport suppresses remote data; never print exception values.
print("BLOCKED: executor_grant_failed_stop_no_retry")
return 1


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading