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
2 changes: 1 addition & 1 deletion control_plane/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -12404,7 +12404,7 @@ def product_action_allowed(
)
if not authz_policy.allows(
identity=identity,
action="launchplane_service_deploy.execute",
action="merge_train.policy_import",
product=merge_train_policy_request.product,
context=_LAUNCHPLANE_SERVICE_CONTEXT,
):
Expand Down
5 changes: 5 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,11 @@ payload/evidence artifact for review.
deploy workflow. Use a Launchplane `GET /v1/health` endpoint reachable from that
runner rather than an internal-only provider hostname.

The manual Merge Train Policy Import workflow uses GitHub OIDC with
`merge_train.policy_import` authority for product/context `launchplane`. It does
not inherit Launchplane self-deploy authority; use that workflow for DB-backed
merge-train policy imports instead of direct DB writes from a local checkout.

The Dokploy-hosted Launchplane target should consume `DOCKER_IMAGE_REFERENCE` from
its env so deploy automation can switch the service by immutable digest and
roll back to the prior digest when verification fails.
Expand Down
4 changes: 2 additions & 2 deletions docs/service-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,8 +406,8 @@ creation and guarded PR-native landing, and writes

`POST /v1/merge-train/policies/import` is the service-owned write path for merge
train policy records. It requires database storage and
`launchplane_service_deploy.execute` on product/context `launchplane`, accepts
`dry_run` and `apply`, and writes the supplied typed record only in apply mode.
`merge_train.policy_import` on product/context `launchplane`, accepts `dry_run`
and `apply`, and writes the supplied typed record only in apply mode.
Shared and production policy changes should use this route rather than direct DB
CLI writes from an arbitrary checkout.

Expand Down
2 changes: 1 addition & 1 deletion scripts/deploy/ensure-authz-grants.sh
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ post_grant \
merge-train-policy-import.yml \
launchplane \
launchplane \
launchplane_service_deploy.execute \
merge_train.policy_import \
deploy:merge-train-policy-import-grant \
merge-train-policy-import
post_grant \
Expand Down
64 changes: 61 additions & 3 deletions tests/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6970,7 +6970,7 @@ def test_merge_train_policy_import_endpoint_writes_active_record(self) -> None:
"event_names": ["workflow_dispatch"],
"products": ["launchplane"],
"contexts": ["launchplane"],
"actions": ["launchplane_service_deploy.execute"],
"actions": ["merge_train.policy_import"],
}
]
}
Expand Down Expand Up @@ -7041,7 +7041,7 @@ def test_merge_train_policy_import_endpoint_dry_run_does_not_write_record(self)
"event_names": ["workflow_dispatch"],
"products": ["launchplane"],
"contexts": ["launchplane"],
"actions": ["launchplane_service_deploy.execute"],
"actions": ["merge_train.policy_import"],
}
]
}
Expand Down Expand Up @@ -7094,6 +7094,64 @@ def test_merge_train_policy_import_endpoint_dry_run_does_not_write_record(self)
self.assertEqual(records, ())
self.assertIsNone(idempotency_record)

def test_merge_train_policy_import_endpoint_rejects_self_deploy_authority(
self,
) -> None:
with TemporaryDirectory() as temporary_directory_name:
root = Path(temporary_directory_name)
database_url = _sqlite_database_url(root / "launchplane.sqlite3")
policy = LaunchplaneAuthzPolicy.model_validate(
{
"github_actions": [
{
"repository": "cbusillo/launchplane",
"workflow_refs": [
"cbusillo/launchplane/.github/workflows/deploy-launchplane.yml@refs/heads/main"
],
"event_names": ["workflow_dispatch"],
"products": ["launchplane"],
"contexts": ["launchplane"],
"actions": ["launchplane_service_deploy.execute"],
}
]
}
)
app = create_launchplane_service_app(
state_dir=root / "state",
verifier=_StubVerifier(
_identity(
repository="cbusillo/launchplane",
workflow_ref=(
"cbusillo/launchplane/.github/workflows/deploy-launchplane.yml@refs/heads/main"
),
event_name="workflow_dispatch",
)
),
authz_policy=policy,
control_plane_root_path=root,
database_url=database_url,
)
record = build_test_merge_train_policy_record(
repository="cbusillo/codex-skills",
record_id="merge-train-policy-codex-skills-self-deploy-denied",
)

status_code, payload = _invoke_app(
app,
method="POST",
path="/v1/merge-train/policies/import",
payload={
"schema_version": 1,
"product": "launchplane",
"mode": "dry_run",
"record": record.model_dump(mode="json"),
},
headers={"Idempotency-Key": "merge-train-policy:self-deploy-denied"},
)

self.assertEqual(status_code, 403)
self.assertEqual(payload["error"]["code"], "authorization_denied")

def test_merge_train_policy_import_endpoint_rejects_non_launchplane_product(
self,
) -> None:
Expand All @@ -7111,7 +7169,7 @@ def test_merge_train_policy_import_endpoint_rejects_non_launchplane_product(
"event_names": ["workflow_dispatch"],
"products": ["launchplane", "other-product"],
"contexts": ["launchplane"],
"actions": ["launchplane_service_deploy.execute"],
"actions": ["merge_train.policy_import"],
}
]
}
Expand Down