Skip to content

feat(control-plane): CITrust reconciler - Keycloak v2 external-internal token-exchange (#819) - #821

Merged
markturansky merged 2 commits into
mainfrom
sdlc/issue-819
Oct 29, 2026
Merged

markturansky merged 2 commits into
mainfrom
sdlc/issue-819

Conversation

@markturansky

Copy link
Copy Markdown
Collaborator

Summary

Implements the CITrust reconciler (issue #819, PLAT-DATA-25), replacing the
stub that made zero Keycloak calls. Realizes keyless CI → gateway federation
through the Keycloak 26.x external-to-internal token-exchange v2 mechanism
(TOKEN_EXCHANGE_EXTERNAL_INTERNAL_V2). The v1 token-exchange permission API
(.../management/permissions) returns 501 on the production fleet Keycloak and
is never called.

This is the code reconciliation of the approved spec PR #820
(specs/platform/ci-trust-ext-api.spec.md, prefix PLAT-CITR).

Refs #819 (non-closing).

What changed

New Keycloak CI-federation surface (components/control-plane/internal/keycloak/ci_federation.go),
idempotent, driven over the v2 admin REST API. A CITrust is realized as three artifacts:

  • (a) Per-repo subject restriction — a per-trust scoped claim-to-workspace IdP
    mapper (oidc-advanced-group-idp-mapper) on the adopted CIProvider IdP. The
    repository / repository_owner (plus project_path / environment / ref /
    ref_protected when set) claim match is the trust boundary (PLAT-CITR-04): a
    token carrying the pinned audience but a non-matching repository lands in no
    workspace group, holds no role, and is rejected default-deny.
  • (b) Token-exchange authorization — opts the gateway client in as the exchange
    audience (standard.token.exchange.enabled) for the shared public, keyless
    federation client (ci-federation).
  • (c) Gateway RBAC binding — binds the CITrust role to the derived workspace
    group on the gateway client.

Delete removes the per-trust subject mapper (the authoritative revocation: the
deleted subject can no longer reach the workspace group). The shared workspace
group, its gateway role binding, and the gateway exchange opt-in are preserved
for sibling trusts (another repo under the same owner, or another trust on the
same gateway), and grant nothing without a matching subject mapper.

Reconciler (components/control-plane/internal/reconciler/ci_trust_reconciler.go):
resolves CIProvider (forge + adopted status.idp_alias) and gateway (Keycloak
client {name}-{id} + owning cluster), derives the forge-namespaced workspace
(gh-<owner> / gl-<namespace>), realizes the artifacts, and writes
status.exchange_endpoint / status.workspace / status.workflow_hint.
Cluster-scoped like the RoleBinding reconciler (only the control plane whose
cluster hosts the gateway acts). Idempotent and re-entrant; a gone CIProvider on
delete is a no-op. NewCITrustReconciler now takes clusterID.

Tests

  • Keycloak methods (ci_federation_test.go): table-driven, httptest-mocked
    admin API — create-all-artifacts, idempotency (create once / update in place /
    no duplicate / no rewrite when already enabled), missing-IdP fails closed,
    missing gateway client is retryable (ClientNotFoundError), unconstrained
    subject rejected, GitLab project_path claim, tighter claims, delete removes
    the subject mapper while preserving the shared binding, delete of an absent
    mapper is a no-op.
  • Reconciler (ci_trust_reconciler_test.go): upsert realizes federation +
    writes status, foreign-cluster skip, gateway-not-found retryable, keycloak
    client-not-ready retryable, delete revokes, cascade-deleted gateway still
    revokes, missing provider on delete is a no-op, nil-keycloak skip, workspace
    derivation.

⚠️ Needs live-cluster verification

Per issue #819 Open Questions, the exact Keycloak 26.6 v2 admin-REST shape for
parts of (a) and (b) cannot be confirmed without a live Keycloak (none available
here). These calls are implemented against the documented v2 admin REST API,
each idempotent and unit-tested against a mock, and marked in code with
LIVE-VERIFY:

  1. (a) The per-repo subject constraint is realized as an advanced-claim IdP
    group mapper. Whether the predicate is best enforced as this IdP mapper vs. an
    external-internal exchange authorization policy (Open Question Add OpenShell gateway provisioning specs from ACP #2) needs
    empirical confirmation on 26.6.
  2. (b) The gateway client is opted in via the standard.token.exchange.enabled
    attribute; the precise per-requester authorization for external-internal v2
    with a public requester (Open Question Restructure: move API server to components/api-server, add specs and skills #1) needs live confirmation.

No client secret is required anywhere (keyless). The subject-predicate trust
boundary and default-deny behavior do not depend on the uncertain endpoints.

Known limitation (pre-existing, not introduced here)

WatchCITrusts logs handler errors without a resync/requeue (unlike
WatchRoleBindings). A reconcile error (including a failed revoke) is retried
only on the next watch reseed (reconnect / idle timeout), not on a periodic
resync. Wiring a resync for CITrust/CIProvider watches is a follow-up.

Spec ID note

Code-comment references to PLAT-CITR-08 / -09 / -05.02 (added by the
approved spec PR #820, not yet on main) are written as prose so this PR is
CI-green off main; the IDs are retained in the commit message and here for
traceability. Once #820 merges they can be tightened.

🤖 Generated with Claude Code

…al token-exchange (#819)

Replace the stubbed CITrust reconciler (which made zero Keycloak calls) with
a real implementation against the Keycloak 26.x external-to-internal
token-exchange v2 mechanism (TOKEN_EXCHANGE_EXTERNAL_INTERNAL_V2). The v1
token-exchange permission API (.../management/permissions) returns 501 on the
production fleet Keycloak and is never called (PLAT-CITR-08).

New keycloak.Client CI-federation surface (internal/keycloak/ci_federation.go),
idempotent and driven over the v2 admin REST API:
- (a) per-trust scoped claim-to-workspace IdP mapper on the adopted CIProvider
  IdP: the repository / repository_owner (plus tighter) claim match is the
  trust boundary; a non-matching token lands in no workspace group and is
  rejected default-deny (PLAT-CITR-04).
- (b) token-exchange authorization opting the gateway client in as the exchange
  audience for the public, keyless federation client.
- (c) gateway RBAC binding of the role onto the derived workspace group.
Delete removes the per-trust subject mapper (authoritative revocation,
PLAT-CITR-05.02); the shared workspace group / role binding / exchange opt-in
are preserved for sibling trusts.

Reconciler (internal/reconciler/ci_trust_reconciler.go): resolve CIProvider
(forge + adopted idp_alias) and gateway (Keycloak client + owning cluster),
derive the forge-namespaced workspace, realize the artifacts, and write status
(exchange_endpoint, workspace, workflow_hint). Cluster-scoped like the
RoleBinding reconciler; a gone CIProvider on delete is a no-op (PLAT-CITR-05.01).
Reconcile is idempotent and re-entrant (PLAT-CITR-09).

Tests: table-driven httptest-mocked admin API for the keycloak methods
(create/idempotency/delete/missing-IdP/missing-client/subject-claim shaping),
and reconciler upsert/delete against a faked keycloak client and faked gRPC
services.

Code-comment references to spec IDs PLAT-CITR-08/09/05.02 (added by the approved
spec PR #820, not yet on main) are written as prose so this PR is CI-green off
main; the IDs are retained here in the commit/PR body for traceability.

Refs #819

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

HyperShell environment destroyed

This ephemeral OpenShift environment has been destroyed. Comment /pr-extend to redeploy it.

@codecov-appsre

codecov-appsre Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.49711% with 109 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...s/control-plane/internal/keycloak/ci_federation.go 62.85% 56 Missing and 35 partials ⚠️
...l-plane/internal/reconciler/ci_trust_reconciler.go 82.65% 11 Missing and 6 partials ⚠️
...ts/control-plane/cmd/hypershell-controller/main.go 0.00% 1 Missing ⚠️
@@            Coverage Diff             @@
##             main     #821      +/-   ##
==========================================
+ Coverage   43.88%   44.31%   +0.43%     
==========================================
  Files         383      384       +1     
  Lines       29514    29822     +308     
==========================================
+ Hits        12951    13215     +264     
- Misses      14923    14924       +1     
- Partials     1640     1683      +43     
Flag Coverage Δ
control-plane 55.03% <68.49%> (+0.88%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ane/internal/reconciler/role_binding_reconciler.go 74.33% <100.00%> (ø)
...ts/control-plane/cmd/hypershell-controller/main.go 5.02% <0.00%> (ø)
...l-plane/internal/reconciler/ci_trust_reconciler.go 84.12% <82.65%> (+84.12%) ⬆️
...s/control-plane/internal/keycloak/ci_federation.go 62.85% <62.85%> (ø)

... and 2 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 78100ec...13b8ba0. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@markturansky markturansky left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review verdict: agent/review-code-rejected. Blocking findings are on the trust boundary (findings 1 and 2). Full rationale is on issue #819. Inline comments mark each finding.

if _, err := c.doRequest(ctx, http.MethodPost, path, body); err != nil {
return fmt.Errorf("bind role %q to workspace group %q on gateway client %q: %w",
spec.Role, spec.Workspace, spec.GatewayClientID, err)
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER (finding 1): cross-repo / cross-gateway privilege escalation. The workspace group is per-owner (gh-), but each trust POSTs its own role onto this one shared group, and POST is additive. Two repos under one owner with different roles both land in gh-, so the lower-privileged repo inherits the sibling's higher role. The per-repo subject mapper gates entry, but the role grant is aggregated per-owner. Bind the role to a per-repo (per-trust) group, or otherwise scope the grant to the subject that the mapper matched.

}
if len(subjectClaims(s.Forge, s.Subject)) == 0 {
return fmt.Errorf("CI federation requires at least one subject claim constraint (the trust boundary); refusing to authorize an unconstrained exchange")
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKER (finding 2): validate() only requires that subjectClaims is non-empty. A github/forgejo trust with only subject_ref or subject_environment (no subject_repository) passes, and the mapper then admits ANY repository building that ref. The comment above says repository/owner is 'the real gate'; enforce it. Require the identity claim (repository/repository_owner for github/forgejo, project_path for gitlab), not just any claim.

case "gitlab":
add("project_path", s.ProjectPath)
add("ref", s.Ref)
add("environment", s.Environment)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 3: the gitlab case omits ref_protected, which the github/forgejo case honors below. A GitLab trust with RefProtected=true silently federates unprotected refs.

func (c *Client) DeleteCIFederation(ctx context.Context, spec CIFederationSpec) error {
if spec.IdpAlias == "" || spec.TrustID == "" {
return fmt.Errorf("CI federation delete requires idp_alias and trust id")
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 4: when a provider resolves but status.idp_alias is empty, no mapper was ever created, yet this returns an error instead of a no-op. Combined with the WatchCITrusts no-resync limitation, a delete can stay failing. Treat an empty idp_alias on delete as nothing-to-revoke.

}
if roleUUID == "" {
return fmt.Errorf("role %q not found on gateway client %q", spec.Role, spec.GatewayClientID)
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding 5: getClientRoleUUID -> doRequest turns any status >= 400 (including 404) into a generic error, so this roleUUID == "" branch is dead code. A provisioning race (client exists, roles not yet created) surfaces as a terminal 'reconcile keycloak federation' error instead of the retryable ClientNotFoundError path used for the client lookup. Map a 404 on the role to the not-ready retry path.

… the trust boundary

Address the code-review findings on #819 / PR #821.

1. BLOCKER - cross-repo / cross-gateway privilege escalation. The role grant
   was aggregated onto a shared per-owner group (gh-<owner>), so a low-privilege
   repo inherited a sibling repo's higher role and roles leaked across gateways.
   Both the IdP entry mapper and the gateway role binding now land on a per-trust
   group keyed on the CITrust id (one repository, one gateway, one role). The
   gh-<owner> workspace stays an informational status/quota label only.
2. BLOCKER - validate() now requires the repository/owner identity claim for
   github/forgejo (project_path for gitlab); a ref/environment-only trust is
   rejected, since the identity claim is the real trust boundary.
3. The gitlab subject predicate now includes ref_protected.
4. DeleteCIFederation is a tolerant no-op on an empty idp_alias / already-gone
   mapper, so a delete cannot wedge given WatchCITrusts does not resync.
5. A missing gateway role now surfaces the retryable ClientNotFoundError path:
   the role lookup inspects the raw 404 (doRequest collapsed every >=400 into a
   generic error, leaving the retry branch dead).

Tests lock each fix, including same-owner-different-repo non-inheritance and
cross-gateway isolation at both the keycloak and reconciler layers. The spec's
PLAT-CITR-02 realization is clarified to mandate per-trust RBAC isolation and the
identity-claim trust boundary, with scenario PLAT-CITR-02.02. LIVE-VERIFY markers
retained.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@markturansky

Copy link
Copy Markdown
Collaborator Author

Review findings addressed (13b8ba0a)

Thanks for the review. All five findings are fixed; the two blockers are reworked at the trust boundary.

1. BLOCKER - cross-repo / cross-gateway privilege escalation. The role grant is no longer aggregated onto a shared per-owner group. Both the IdP entry mapper and the gateway role binding now land on a per-trust group keyed on the CITrust id (CIFederationSpec.GroupName() = <workspace>-citrust-<trustID>). Since one CITrust is exactly one (repository, gateway, role), a repo only ever receives the role its own CITrust grants on its own gateway - no sibling-repo inheritance, no cross-gateway leak. gh-<owner> is kept only as the informational status/quota workspace label; it is never the RBAC group. Locked by TestEnsureCIFederation_SameOwnerDifferentReposDoNotCrossInherit, TestGroupName_PerTrustAndPerGateway, and the reconciler-level TestCITrustUpsert_CrossGatewayAndCrossRepoIsolation.

2. BLOCKER - identity-claim trust boundary. validate() now requires the identity claim: repository or repository_owner for github/forgejo, project_path for gitlab. A ref/environment-only subject is rejected. Locked by TestEnsureCIFederation_RejectsIdentityClaimlessSubject (table-driven across github/forgejo/gitlab).

3. GitLab ref_protected. Added to the gitlab predicate, mirroring github/forgejo. Locked by TestEnsureCIFederation_GitLabRefProtectedIncluded.

4. Tolerant delete. DeleteCIFederation is now a no-op on an empty idp_alias/trust id (and the mapper delete was already a no-op when already gone), so a delete cannot wedge given WatchCITrusts does not resync. Locked by TestDeleteCIFederation_EmptyIdPAliasIsNoOp. The no-resync limitation is noted in the godoc.

5. Retryable missing-role path. The role lookup (getFederationClientRoleUUID) now inspects the raw response and maps a 404 to ClientNotFoundError (the retry path), instead of letting doRequest collapse every >=400 into a generic terminal error - which had left the roleUUID == "" branch dead. Locked by TestEnsureCIFederation_MissingRoleIsRetryable.

The PLAT-CITR-02 realization spec is clarified to mandate per-trust RBAC isolation and the identity-claim trust boundary (new scenario PLAT-CITR-02.02), so a future reconcile cannot reintroduce the per-owner aggregation. LIVE-VERIFY markers for the v2 admin-REST shapes are retained.

Local gates green: go build, go vet, gofmt, golangci-lint (0 issues), control-plane unit tests, and the full make check policy suite.

@markturansky

Copy link
Copy Markdown
Collaborator Author

Re-review (iteration 2) - APPROVE

Both iteration-1 BLOCKING findings are genuinely fixed and locked by non-vacuous tests. Verified against the code, not just the commit message.

Blocker 1 - cross-repo / cross-gateway privilege escalation: FIXED

The role grant and the IdP entry mapper now both target the same per-trust group, keyed on the CITrust id:

  • Entry mapper: ci_federation.go:292 -> "group": "/" + spec.GroupName()
  • Role binding: ci_federation.go:386 -> group := spec.GroupName()
  • GroupName() (:120) = <workspace>-citrust-<trustID>; the gh-<owner> workspace is only a name prefix and never widens scope.

No remaining code path grants a trust's role onto a shared per-owner group. Tests prove it against real fake-Keycloak state (distinct group ids, recorded role mappings), not string comparison:

  • TestEnsureCIFederation_SameOwnerDifferentReposDoNotCrossInherit - two trusts, same owner, different repos/roles; asserts each group carries only its own role and each mapper admits only its own repository.
  • TestEnsureCIFederation_CreatesAllArtifacts - asserts the role is bound to exactly one group.
  • TestGroupName_PerTrustAndPerGateway + reconciler-level TestCITrustUpsert_CrossGatewayAndCrossRepoIsolation - cross-gateway isolation.

Blocker 2 - validate() must require an identity claim: FIXED

validate() (:230-239) now rejects a ref/environment-only subject: requires repository or repository_owner for github/forgejo, project_path for gitlab. TestEnsureCIFederation_RejectsIdentityClaimlessSubject covers all five cases and asserts no mapper is created on rejection.

Also confirmed: gitlab ref_protected claim included (:259-260, test), tolerant delete on empty alias (:198-202, test), retryable 404 for a missing gateway role (:396-399, test).

CI

Code-gating checks green: Go - control plane, Unit / Go - control plane, Repository policy, Konflux control-plane, codecov, both CI Gates. E2E / Kind and E2E / Deploy OpenShift pending - non-blocking.

Non-blocking follow-ups (do not gate this PR)

  1. ensureGatewayExchangeAuthorization enables standard.token.exchange.enabled on the gateway client globally; the per-requester (ci-federation client -> gateway audience) exchange permission is still the author's documented LIVE-VERIFY item. The per-trust subject->group mapper is the enforced boundary today.
  2. An org-wide/malformed subject_repository with no / yields a repository==<owner> claim no forge token satisfies -> trust admits nothing while reporting Ready. Consider rejecting or supporting owner-only intent explicitly.
  3. resolveGatewayClientID lacks the nil-gateway guard that resolveProvider has.
  4. Delete revocation depends on the provider's current status.idp_alias; a re-created/unrealized alias can leave a live mapper since WatchCITrusts does not resync deletes.

Verdict: APPROVE. The two blockers are closed and proven; remaining items are hardening follow-ups.

@markturansky
markturansky added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 086ae16 Oct 29, 2026
35 checks passed
@markturansky
markturansky deleted the sdlc/issue-819 branch October 29, 2026 11:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant