Skip to content

feat(projects): add ServiceAccount and ServiceAccountAccessToken - #369

Merged
markussiebert merged 4 commits into
masterfrom
feat/project-service-account-access-token
Jul 3, 2026
Merged

feat(projects): add ServiceAccount and ServiceAccountAccessToken#369
markussiebert merged 4 commits into
masterfrom
feat/project-service-account-access-token

Conversation

@markussiebert

Copy link
Copy Markdown
Collaborator

Description

Adds project-scoped ServiceAccount and ServiceAccountAccessToken managed
resources, completing the service-account family alongside the group resources
(#326) and the instance resources (#368).

  • ServiceAccount — manages a GitLab project service account.
  • ServiceAccountAccessToken — manages the personal access token of a
    project service account, keyed by serviceAccountId. Two modes, selected
    automatically:
    • Owner mode (default): the ProviderConfig authenticates with a token that
      can manage the project's service-account tokens; create/observe/rotate/revoke
      go through the project service-account token endpoints.
    • Self mode: the ProviderConfig authenticates with the very token this
      resource manages (self-sustaining rotation loop) and uses the self
      endpoints.

Both cluster-scoped and namespaced variants are generated.

Notes for reviewers

The ServiceAccountAccessToken implementation mirrors the group resource from
#326 (it is the same logic with GroupProject renames). The review fixes
raised on #326 are included here from the start:

  • UseLegacyProviderConfig populates CredentialsSecretRef (self-mode detection
    depends on it) — inherited from master.
  • Self-mode safety guard: refuse to manage the token when it does not belong to
    spec.forProvider.serviceAccountId (guards against a miswired credentials
    secret rotating/revoking the wrong service account's token).
  • Owner-mode rotation only falls back to a fresh create when the existing token
    is genuinely gone (404, or 400 "token already revoked"); any other error
    surfaces rather than silently minting a second token.
  • The owner-mode list filters server-side with state=active.
  • Cluster-scope self-mode detection compares the write reference's namespace
    (w.Namespace) instead of cr.GetNamespace(), since cluster-scoped resources
    have no namespace.

How was it tested

  • go build ./..., go vet ./... — clean.
  • go test ./... — full suite passes.
  • Unit tests cover self-mode service-account match/mismatch and owner-mode
    rotation fall-through (404 and 400-revoked), in both namespaced and cluster
    scopes.

…ged resources

Adds project-scoped service account support, reaching parity with the group and
instance service account resources.

projects.gitlab.m.crossplane.io/v1alpha1 ServiceAccount manages a project service
account (create/observe/update/delete) via the project service-account endpoints,
keyed by projectId. Observe lists the project's service accounts and matches by id
(there is no get-by-id endpoint).

projects.gitlab.m.crossplane.io/v1alpha1 ServiceAccountAccessToken manages the
personal access token of a project service account, keyed by projectId and
serviceAccountId, mirroring the group resource. It has two modes, selected
automatically.

Owner mode (default): the ProviderConfig is a project owner/maintainer and the
token is managed via the project service-account token endpoints:

- Create  -> Projects.CreateProjectServiceAccountPersonalAccessToken
- Observe -> Projects.ListProjectServiceAccountPersonalAccessTokens (match by id)
- Rotate  -> Projects.RotateProjectServiceAccountPersonalAccessToken
- Revoke  -> Projects.RevokeProjectServiceAccountPersonalAccessToken

Self-managed mode: when the referenced ProviderConfig authenticates with the very
token this resource writes to its connection secret (detected when the
PersonalAccessToken credential secretRef matches writeConnectionSecretToRef by
namespace, name and key), the provider acts as the service account itself and uses
the self endpoints (self-inform / self-rotate / self-revoke). A dead self-token
surfaces as a clear terminal error. A SelfManaged status condition reports the mode.

In owner mode the rotate-vs-create decision is made on external-name presence, and
a failed rotation only falls through to a fresh create when the token no longer
exists (404); any other rotation error is surfaced so a still-valid token is never
orphaned.

The external name is the token id and the token value is written to the connection
secret on create/rotate. expiresAt/renewalPeriodDays and renewBeforeDays rotation
semantics match the group resource. projectId, serviceAccountId, name and scopes
are immutable (enforced via CEL); the rotation-timing fields stay mutable. The
cluster-scoped variants, CRDs and generated code are produced by make generate.

Closes #329

Signed-off-by: Markus Siebert <markus.siebert@deutschebahn.com>
Resolves 7 HIGH-severity CVEs reported by the Trivy fs scan on the tools module (CVE-2026-25680, -25681, -27136, -33814, -39821, -42502, -42506). golang.org/x/net v0.55.0 requires go >= 1.25, so the tools module go directive and the accompanying golang.org/x/* indirect deps were updated by go mod tidy.

Signed-off-by: Markus Siebert <markus.siebert@deutschebahn.com>
@derbauer97

Copy link
Copy Markdown
Contributor

Review + live e2e battle-test

Reviewed and ran a full end-to-end test on the same setup as #368: kind + Crossplane 2.3.3, provider out-of-cluster against a self-managed GitLab 18.11.5-ee instance, exercising both the namespaced (projects.gitlab.m.crossplane.io) and cluster-scoped (projects.gitlab.crossplane.io) variants of both new resources.

This one is in good shape. Crucially, it mirrors the group resource (#326) using scoped project endpoints, so it does not carry the rotate-ownership issue I flagged on the instance PR (#368).

Verified working end-to-end

ServiceAccount (scoped *ProjectServiceAccount* endpoints):

  • Create -> Ready/Synced, SA created in the project, atProvider populated.
  • Update -> renaming via spec.forProvider.name propagates to GitLab; resource stays stable (no condition flapping / drift loop).

ServiceAccountAccessToken:

  • Owner mode: create/observe -> Ready/Synced, token authenticates as the project service account (user_id matches), written to a connection.crossplane.io/v1alpha1 secret.
  • Self mode: auto-detection (SelfManaged=True) + self-rotation (token id changes, new value written back, loop stays Ready/Synced).
  • Self-mode service-account mismatch guard: refuses with the terminal error, no rotate/revoke.
  • CEL: projectId/serviceAccountId/name/scopes immutable; expiresAt XOR renewalPeriodDays and the renewBeforeDays dependency rules enforced.

Security: the #368 rotate-ownership issue does NOT apply here

On #368 I showed that a drifted/attacker-set crossplane.io/external-name made the instance controller rotate an arbitrary instance token (via the unscoped admin RotatePersonalAccessTokenByID) and leak it into the tenant secret. I tried the same exploit here:

  • Drifted external-name to a token id owned by an admin user, while serviceAccountId = the project SA.
  • The scoped RotateProjectServiceAccountPersonalAccessToken(project, sa, id) returned 404 for the foreign id, so the controller fell back to creating a fresh token for the correct SA. The foreign token was left untouched (active:true), and the connection secret got a token owned by the intended SA — not the admin.

I also confirmed owner-mode create with a serviceAccountId that isn't a project service account (a human admin) fails with 404 Not Found and writes no token — so the "mints PATs for arbitrary users" concern from #368 doesn't apply either; the scoped create endpoint validates membership for you.

In short, by keeping the group resource's scoped endpoints (rather than unscoped admin by-id), this PR avoids both #368 findings by construction. 👍

Minor (non-blocking)

  • The self-mode example in examples/projects/serviceaccountaccesstoken.yaml omits serviceAccountId, which disables the self-mode mismatch guard in the shipped example. Suggest setting it (same note I left on feat(instance): add ServiceAccountAccessToken managed resource #368).
  • No shortName on the new project CRDs (group SAAT has saat); a short name would be a small kubectl UX win.

Open question

  • ServiceAccount leaves name/username mutable (only email is +immutable, and that's a doc marker, not CEL-enforced). Intentional? GitLab generates username/email when unset; leaving them unset reconciled cleanly in my test, but worth confirming you want rename-in-place semantics rather than immutability.

…e-account-access-token

# Conflicts:
#	README.md
- add serviceAccountId to self-mode SAAT example to enable mismatch guard
- add shortName 'psaat' to project ServiceAccountAccessToken CRD
- make ServiceAccount email mutable (rename-in-place) across group/instance/project
- regenerate CRDs

Signed-off-by: Markus Siebert <markus.siebert@deutschebahn.com>
@markussiebert
markussiebert force-pushed the feat/project-service-account-access-token branch from 26c9938 to 11b5c23 Compare July 2, 2026 08:42
@markussiebert
markussiebert requested a review from dariozachow July 3, 2026 08:52
@markussiebert markussiebert self-assigned this Jul 3, 2026
@markussiebert
markussiebert merged commit 6fcc38a into master Jul 3, 2026
8 checks passed
@markussiebert
markussiebert deleted the feat/project-service-account-access-token branch July 3, 2026 10:49
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.

3 participants