Skip to content

feat(s2s): bearer-auth every service-to-service call#79

Merged
BK1031 merged 3 commits into
mainfrom
bk1031/feat-s2s-auth
Jun 17, 2026
Merged

feat(s2s): bearer-auth every service-to-service call#79
BK1031 merged 3 commits into
mainfrom
bk1031/feat-s2s-auth

Conversation

@BK1031

@BK1031 BK1031 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Pre-seed an SA per non-core service (sentinel-discord, sentinel-oauth, sentinel-saml) in core's init job, each with sentinel:all + never-expires. The seed path bypasses the UI scope allow-list — admins still can't mint sentinel:all through the SA UI.
  • New `POST /core/internal/bootstrap-token` endpoint. Non-core services exchange `INTERNAL_BOOTSTRAP_SECRET` (constant-time compared) for their pre-seeded bearer JWT. Closed-set name allowlist, fails closed when the secret isn't configured.
  • All three sentinel clients gain SetBearer + Bootstrap. Each service's main.go calls Bootstrap after kerbecs.Init; subsequent outbound calls automatically carry Authorization: Bearer.
  • Compose + example.env get the new env var.

Rolled-in fix

  • Discord group sync's duplicate-key error ("already a member via DIRECT, ADD via DISCORD trips the PK"): the reconcile now reads all-source memberships and only the DELETE step considers DISCORD-sourced rows. Delete is already source-scoped, so non-DISCORD rows stay safe.

To test

  1. Set `INTERNAL_BOOTSTRAP_SECRET` to some high-entropy string in your `.env`.
  2. `docker compose up -d --force-recreate core discord oauth saml`.
  3. Watch core logs for `Created internal service account sentinel-discord` etc.
  4. Watch each non-core service for `bootstrap attempt 1` (or success on first try).
  5. Any internal call (e.g. discord sync) now sends Authorization: Bearer to the receiving side.

Test plan

  • Core seed creates 3 internal SAs on a fresh DB; idempotent on restart
  • Bootstrap endpoint rejects wrong secret with 401
  • Bootstrap endpoint rejects an admin-created SA name with 400
  • Each non-core service successfully bootstraps and starts serving
  • Discord group sync no longer logs duplicate-key on users already in a group via DIRECT
  • sentinel:all token on a service is accepted by every gated endpoint

BK1031 added 3 commits June 17, 2026 00:44
…vice call

Closes the gap where service-to-service traffic went out unauthenticated
and the receiving side's RequestTokenHasScope checks silently saw an
empty scope. Every non-core service now boots, swaps a shared secret
for its pre-seeded bearer JWT, and includes it on every outbound
sentinel-client call. The receiving end goes through the existing
JWT validation path — no special-casing.

Core
  - core/jobs/init.go: initializeInternalServiceAccounts seeds an SA
    per known internal service (sentinel-discord, sentinel-oauth,
    sentinel-saml), each scoped sentinel:all and never-expires. The
    seed path uses CreateServiceAccountForApp + MintServiceAccountToken
    directly, bypassing the UI scope allow-list — internal automations
    legitimately need broad access for system work. Idempotent on both
    SA existence and SignedToken presence.
  - core/api/bootstrap.go: POST /core/internal/bootstrap-token
    exchanges INTERNAL_BOOTSTRAP_SECRET (via X-Bootstrap-Secret header,
    constant-time compared) for the named SA's persisted bearer JWT.
    Closed-set name allowlist via jobs.IsInternalServiceAccountName so
    a leaked secret can't be used to harvest admin-created SAs. Fails
    closed when the secret isn't configured server-side.
  - core/config: INTERNAL_BOOTSTRAP_SECRET env var.

Per-service sentinel clients (discord/oauth/saml)
  - SetBearer + sync-protected bearer state; do() automatically
    attaches Authorization: Bearer <token> when set.
  - Bootstrap(serviceName, secret) helper handles the exchange with
    linear-backoff retries (~10s total) for the compose boot race.
  - Each service's main.go calls Bootstrap after kerbecs.Init and
    before any other sentinel call. Fatal on failure — compose's
    restart: always handles persistent races.
  - Each service's config gains InternalBootstrapSecret +
    InternalServiceName (constant per service).

discord group sync — rolled in
  - Fixes the duplicate-key error you've been seeing: the reconcile
    now reads ALL of an entity's group memberships, not just
    DISCORD-sourced ones, so the ADD path skips groups where the
    entity already exists via DIRECT/CONDITIONAL. Delete loop stays
    source-scoped — never touches non-DISCORD rows.

docker-compose + example.env
  - INTERNAL_BOOTSTRAP_SECRET passed through to core/discord/oauth/saml
    as a pure env-var reference (per the no-defaults rule).
The seeded SA used "Sentinel Core" — Title Case with a space — which
stuck out next to the internal SAs that match docker container naming
(sentinel-discord, sentinel-oauth, sentinel-saml).

Renames the seed to "sentinel-core" and adds an idempotent UPDATE so
existing DBs with the old name get migrated on next boot.
The new SA gates (requireAppOwnerOrAdmin, GetServiceAccountToken) only
accepted owner/admin/creator. That broke the codebase-wide convention
where sentinel:all is the universal first-party bypass — meaning an
internal automation carrying sentinel:all couldn't manage SAs even
though every other admin-flavored endpoint accepts it.

Adding sentinel:all to the Any() in both gates so the model stays
uniform: scope:sentinel:all = "I'm an internal automation, skip
everything." No code path needs to add internal SAs to the Admins
group as a workaround.
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