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
22 changes: 12 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
Expand All @@ -35,7 +35,7 @@ jobs:
run: go vet ./...

- name: golangci-lint
uses: golangci/golangci-lint-action@d583c34f0599d37dbac4a198b9c83201be380893 # v9.3.0
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.13.1

Expand All @@ -52,7 +52,7 @@ jobs:
# fuzz-short:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
# with:
# go-version-file: go.mod
Expand All @@ -78,17 +78,17 @@ jobs:
env:
IMAGE: ghcr.io/${{ github.repository }} # org-agnostic; the image lives under the repo
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Compute version (branch-shortsha, baked into main.Version)
id: ver
run: echo "version=${GITHUB_REF_NAME}-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0

- name: Tags + labels
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.IMAGE }}
tags: |
Expand All @@ -98,7 +98,7 @@ jobs:

# Build once, locally, so the SBOM + Trivy scan act on the EXACT bits before any push.
- name: Build image (load, no push yet)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./Dockerfile
Expand All @@ -113,7 +113,7 @@ jobs:
cache-to: type=gha,mode=max

- name: SBOM (syft, SPDX JSON)
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2
with:
image: ${{ env.IMAGE }}:${{ github.sha }}
artifact-name: sbom-${{ github.event.repository.name }}-${{ github.sha }}.spdx.json
Expand All @@ -123,13 +123,15 @@ jobs:
# own-built image → exit-code: '1' (block HIGH/CRITICAL — the default here)
# upstream base we don't rebuild → exit-code: '0' (report-only)
- name: Trivy scan gate (HIGH,CRITICAL)
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
# The scanner itself, pinned: the action's own default lags its releases.
version: v0.74.0
image-ref: ${{ env.IMAGE }}:${{ github.sha }}
severity: HIGH,CRITICAL
exit-code: '1'

- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
env:
IMAGE: ghcr.io/${{ github.repository }} # org-agnostic; the image lives under the repo
steps:
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
Expand Down
159 changes: 159 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,167 @@
Notable changes to this service, newest first, per release. This file is written for whoever
runs the service or integrates against it.

## v0.1.2

### Changed — a token is minted only from a register answer about the person who signed in

With a membership register wired, the register's resolve answer now has to name the subject it is
about (`subjectKey`), and it has to be the subject asked about. An answer about anyone else, or one
that names nobody, refuses the token instead of minting its memberships:

```
POST /token (a user token, register wired)
→ 502 { "code": "err:upstream:unavailable" } when the register's answer is not about this person
```

Every resolve also writes one info line, `membership resolved`, with the key asked, the key
answered for, and each membership's tenant and scopes. **Upgrade the register first:** a register
that does not yet name its subject makes every login that needs a membership fail this way.


### Changed — signing out no longer ends a directory provider's session

Signing out through `GET /logout` used to send every upstream login on through the provider's
end-session endpoint, ending the session the provider keeps in the browser. That is right for a
provider whose short-lived SSO session on a shared device would otherwise sign the next person in
as the previous one — the eParaksts profile keeps doing it — and wrong for a directory provider,
whose session is the person's whole estate: signing out of this application signed them out of
their mail and documents too, after a page asking which account to sign out of. The generic
connector now signs out **locally by default**: this service's session ends and the browser
returns to `redirect_uri`; the provider's session is left alone. A deployment that wants the
front-channel hop for a generic provider lists the methods:

```
OIDC_UPSTREAM_METHODS_FEDERATED=upstream
```

The logout audit event's `federated` attribute says which of the two happened. Nothing changes for
the eParaksts profile.

### Added — the generic connector verifies the provider's id_token

When the upstream provider publishes a key set — its discovery document names a `jwks_uri`, as
every mainstream provider's does, or `OIDC_UPSTREAM_JWKS_URL` and `OIDC_UPSTREAM_ISSUER` are set
for a provider configured by explicit endpoints — every login must now carry an id_token that
verifies: signed with one of the published keys (RS256 · PS256 · ES256 only), `iss` equal to the
issuer, `aud` containing this client, `exp` and `iat` within `TOKEN_CLOCK_SKEW_LEEWAY`, the `nonce`
this service sent with the authorize request, and a subject equal to userinfo's. A missing or
failing id_token refuses the login — `401`, the reason in the login-failure audit event, no claim
value in it. The id_token's claims win over userinfo's where both carry one, and its `acrs` join
the `amr` list for the `LOA_POLICY` vocabulary. **Without a key set nothing changes**: the eParaksts
profile and any provider with fixed paths and no `jwks_uri` stay userinfo-only.

Two claim maps for an organisation whose people sign in through its own directory:
`OIDC_UPSTREAM_CLAIM_DIRECTORY_ID` (default `sub`; `oid` for Microsoft Entra ID) names the durable
identifier the person's credential is stored under, and `OIDC_UPSTREAM_CLAIM_ACCOUNT_STATUS`
(`acct` for Entra) with `OIDC_UPSTREAM_ACCOUNT_STATUS_GUEST` (default `1`) tells a member of the
directory from a guest in it.

```
OIDC_UPSTREAM_AUTHORITY_URL=https://login.microsoftonline.com/<tenant-id>/v2.0
OIDC_UPSTREAM_SCOPES=openid profile email
OIDC_UPSTREAM_CLAIM_SERIAL= # empty: a directory carries no identity code
OIDC_UPSTREAM_CLAIM_DIRECTORY_ID=oid
OIDC_UPSTREAM_CLAIM_ACCOUNT_STATUS=acct
OIDC_UPSTREAM_METHOD_DEFAULT=upstream
OIDC_UPSTREAM_METHODS_ALLOWED=upstream
OIDC_UPSTREAM_LOA_DEFAULT=low
```

**A login without an identity code is no longer refused.** The identity store creates the person
without one, resolved by their credential on every later login; such a person stays separate from
the same human's card login until linked by a deliberate act (a later release). Requires the
platform database at `identity/V3` — against an older one the login is refused as before.

### Added — a login through an organisation's directory is admitted into the tenant that attached it

At token issue, a person who came through the upstream provider and holds no membership is offered
to the membership register's admission door (`POST /api/v1/directory-admissions`, on the
`membership:claim` scope this service already holds toward the register — **no registry change**):
the tenant that attached that issuer as its directory admits them as an active member with **no
grants**, and the token is minted for that tenant with an empty scope set — a member who holds
nothing until an administrator assigns a role. A guest in the provider's directory is not offered
and is refused as before (`403 err:membership:notMember`); so is everyone whose issuer no tenant
attached. A person already holding a membership is never offered, so a refresh costs no extra
call. **Requires a register release that exposes the door**: an older register answers `404`, and
the issue fails closed (`502 err:upstream:unavailable`) for exactly the people who would have been
admitted — deploy the register first.

### Changed — two configured upstream families refuse to start

Setting both `OIDC_UPSTREAM_*` and `EPARAKSTS_*` used to select the generic connector silently.
The service now refuses to start: *two upstream identity providers are configured (OIDC_UPSTREAM_*
and EPARAKSTS_*): this service runs exactly one — unset one family*. A deployment that carried
eParaksts placeholders beside a generic connector removes them.

### Changed — the membership register is asked by the person's platform subject, never their identity code

At token issue this service asks the membership register which organisations a person belongs to. It
asked by the person's national identity code, typed `pno:<code>`; it now asks by their **platform
subject** — the stable id the identity store keys the person on, which is the token's own `sub` —
typed **`sub:<person id>`**. A service account is still asked for by its client id (`svc:<client id>`).
No token changes shape: the key is a function of `sub`, and every consumer derives it the same way, so
nothing new is minted and the identity code (`serial_number`) stays on the token for the signing side.

```
register lookup, before: claim + resolve by "pno:PNOLV-XXXXXXXXXXX"
register lookup, after: claim + resolve by "sub:01J8X2K4M9N7P3Q5R6S8T0V1W2" (= "sub:" + the token's sub)
```

**Removed with it:** the refusal *"the login carries no identity code"* (a 502 at token issue). A
session always has a subject, so a login method that supplies no identity code can be issued a token
and resolved against the register like any other.

**Deploy order.** The register's database migration that retires the `pno:` kind comes first, then the
register service and this service together. Against an older register this service's `sub:` keys are
refused by the register's constraint; an older authorization server's `pno:` keys are refused by the
new register at every write door and resolve to nobody. Neither combination runs; migrate, then redeploy.

### Added — `POST /identity/persons`: a person gets a platform subject before their first login

An administrator registering or inviting somebody — or recording a person who will never log in —
needs that person's platform subject to register them under, and until now a subject existed only
once the person had logged in. The new door creates the person row in the identity store with the
canonical identity code and whatever name is known, and **no credential**: a record, not an account.
Nothing can log in as them until a login attaches a credential, and that login lands on this row
because the same canonical code is matched there.

```
POST /identity/persons Authorization: DPoP <token carrying identity:admin>
{"identityCode": "PNOLV-XXXXXX-XXXXX", "name": "…", "givenName": "…", "familyName": "…"}

201 {"personSub": "01J8X2K4M9N7P3Q5R6S8T0V1W2", "created": true} the person is new
200 {"personSub": "01J8X2K4M9N7P3Q5R6S8T0V1W2", "created": false} already known (registered, or logged in before);
names are filled only where the row has none
422 err:identity:invalid the code names no country, or an identity type this platform does not know — never echoed
403 err:identity:forbidden the token does not carry identity:admin
```

The scope is minted like every other: a role in the membership register grants it to an
administrator; for bring-up a registry client may hold it as a service-token grant. Each registration
is recorded as a GDPR-audit identity write (the administrator as actor, the person as subject), routine
and fail-open like the login path's own record. **Nothing to configure.**

## v0.1.1

### Removed — `OIDC_UPSTREAM_COUNTRY`

The setting is gone. It supplied a country for an identity-code claim that carried none, so that a
bare national code could be stored in the canonical `PNO<CC>-<code>` form. Two things were wrong with
it: the variable was never bound to the configuration in the first place, so setting it had no effect
and produced no warning; and the design was unsound even had it worked, because one value applies to
every person who logs in through the provider. A deployment whose users hold codes from more than one
national register would have filed some of them under another register's country — a perfectly valid
key belonging to the wrong person, with nothing to notice it by.

**If you set it:** nothing changes, because nothing was reading it. No deployment behaviour differs.

**What the service does now:** the claim named by `OIDC_UPSTREAM_CLAIM_SERIAL` must carry an identity
code that states its own country (`PNOLV-XXXXXXXXXXX`). A bare code refuses the login, as it already
did. The remedy is a claim mapping at your identity provider, where the identity **type** can be
stated alongside the country rather than assumed. Card login is unaffected: it takes the country from
the card certificate's own subject attribute, which is a fact about the person holding the card.

### Added — tenant service accounts: a machine that is a member of an organisation

A registered service client can now act **for an organisation** rather than only as itself. At
Expand Down
Loading
Loading