diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7dead64..05562c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -208,7 +208,8 @@ jobs: { printf '# PastureStack Authentication Service %s\n\n' "$RELEASE_TAG" - printf 'This release preserves an explicit empty OIDC allowlist on the platform API wire. It avoids the generated setting client omitting an empty value, so a confirmed unrestricted transition durably clears stale restricted identities. Access-only changes continue to skip discovery and provider reload; access expansion remains protected by a single-use MFA confirmation bound to the operator and canonical request digest.\n\n' + printf 'This release makes the common OIDC site-access policy authoritative after the encrypted authentication configuration has been created. Authentication-service startup no longer replays absent legacy OIDC keys over a saved restricted or unrestricted policy, so access mode and allowlist values survive process and Server container restarts. The one-time legacy migration path remains available before the canonical configuration exists.\n\n' + printf 'It also preserves the previous explicit-empty allowlist wire contract: a confirmed unrestricted transition durably clears stale restricted identities. Access-only changes continue to skip discovery and provider reload; access expansion remains protected by a single-use MFA confirmation bound to the operator and canonical request digest.\n\n' printf '## Immutable coordinates\n\n' printf -- '- Source commit: `%s`\n' "$SOURCE_SHA" printf -- '- Artifact SHA-256: `%s`\n\n' "$artifact_sha" diff --git a/.github/workflows/security-release-gate.yml b/.github/workflows/security-release-gate.yml index c34320f..57bb2e2 100644 --- a/.github/workflows/security-release-gate.yml +++ b/.github/workflows/security-release-gate.yml @@ -20,7 +20,7 @@ jobs: env: DAPPER_IMAGE: pasturestack/authentication-service-dapper:${{ github.sha }} TRIVY_IMAGE: aquasec/trivy:0.73.0@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c - VERSION_OVERRIDE: v0.4.39 + VERSION_OVERRIDE: v0.4.40 steps: - name: Check out candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -70,7 +70,7 @@ jobs: } run_ci - artifact="dist/artifacts/authentication-service-0.4.39-linux-amd64.tar.xz" + artifact="dist/artifacts/authentication-service-0.4.40-linux-amd64.tar.xz" test -s "$artifact" cp "$artifact" /tmp/authentication-service-first.tar.xz rm -rf bin dist @@ -81,7 +81,7 @@ jobs: tar -xJf "$artifact" -C evidence/product test -x evidence/product/authentication-service test "$(find evidence/product -maxdepth 1 -type f | wc -l)" -eq 1 - evidence/product/authentication-service --version | grep -F '0.4.39' >/dev/null + evidence/product/authentication-service --version | grep -F '0.4.40' >/dev/null sha256sum "$artifact" > evidence/authentication-service.tar.xz.sha256 docker run --rm --entrypoint go \ --volume "$PWD:/work:ro" \ diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index a428160..77f9508 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -31,6 +31,13 @@ The empty allowlist must be present as an explicit `value: ""` field in the platform setting update. Generated client omission rules must not turn the clear operation into a no-op. +Legacy provider settings are imported only while no encrypted `auth.config` +object exists. After that migration boundary, the common access-policy +settings are authoritative: startup and restart must not copy absent legacy +OIDC keys over a saved access mode or allowlist. Both an explicit empty +unrestricted allowlist and a populated restricted/required allowlist must +round-trip across authentication-service and Server container restarts. + Operator lifecycle messages support `en-US` and `zh-TW`. Tokens, usernames, groups, identity-provider data, OpenID Connect claims, SAML documents, database settings, HTTP payloads, and protocol errors are not translated. diff --git a/README.md b/README.md index 05e90b2..2ca104d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status -The current compatibility release is `v0.4.39`. It retains the existing Ubuntu 26.04, +The current compatibility release is `v0.4.40`. It retains the existing Ubuntu 26.04, Go 1.27.0, JWT, cookie, TLS, LDAP, GitHub, Shibboleth, dependency, and build maintenance. It adds a provider-neutral OpenID Connect authorization-code client with discovery, PKCE S256, nonce validation, @@ -21,7 +21,7 @@ single-use signed identity proof. The control platform uses that proof for an explicit account-link or reassignment decision; profile fields are never trusted as implicit account-matching keys. -Release `v0.4.39` separates OIDC identity-source changes from site-access +Release `v0.4.40` separates OIDC identity-source changes from site-access policy changes. An already-enabled provider can change access mode and its OIDC user/group allowlist without repeating discovery, emitting a provider reload generation, or repeating the five-minute local recovery ceremony. @@ -37,6 +37,12 @@ The unrestricted transition sends an explicit empty allowlist value on the platform API wire. This prevents the generated client's `omitempty` behavior from turning a requested clear into an omitted field and retaining stale restricted identities in the database. +The legacy-settings importer now runs only before the encrypted `auth.config` +object exists. Once migration has completed, a process restart cannot replay +empty legacy OIDC keys over the authoritative access mode or allowlist. This +keeps restricted `oidc_user` and `oidc_group` entries intact across service and +Server container restarts while retaining the one-time migration path for old +installations. Product-owned imports, executable names, CLI settings, client variables, and operator messages use PastureStack naming. @@ -55,9 +61,9 @@ make build make package ``` -Set `VERSION_OVERRIDE=v0.4.39` for the reviewed identity-security compatibility +Set `VERSION_OVERRIDE=v0.4.40` for the reviewed identity-security compatibility release. Packaging produces the deterministic, versioned -`authentication-service-0.4.39-linux-amd64.tar.xz` asset. The manually +`authentication-service-0.4.40-linux-amd64.tar.xz` asset. The manually dispatched release workflow runs the full test and validation suite twice, requires byte-identical packages, verifies a fixed and attested security scanner, publishes CycloneDX SBOMs and scan evidence, and publishes the diff --git a/server/auth_server.go b/server/auth_server.go index 9a5b29d..8c0fb10 100644 --- a/server/auth_server.go +++ b/server/auth_server.go @@ -355,6 +355,18 @@ func readSettings(provider string) (map[string]string, error) { return providerSettings, nil } +func storedAuthConfigExists() (bool, error) { + if PlatformClient == nil { + return false, fmt.Errorf("platform API client is not configured") + } + filters := map[string]interface{}{"key": "auth.config"} + authColl, err := PlatformClient.GenericObject.List(&client.ListOpts{Filters: filters}) + if err != nil { + return false, err + } + return len(authColl.Data) > 0, nil +} + func readCommonSettings(settings []string) (map[string]string, error) { var dbSettings = make(map[string]string) if PlatformClient == nil { @@ -768,6 +780,20 @@ func localRecoveryReady(settings map[string]string, now time.Time) bool { // UpgradeSettings upgrades the existing provider specific auth settings to the new generic settings used by this service func UpgradeSettings() error { + // Legacy provider settings are a one-time migration source. Once the + // encrypted auth.config object exists, the common settings are authoritative + // and must not be overwritten on every process restart. OIDC has no legacy + // access-policy keys, so repeating this migration used to clear a valid + // restricted allowlist after an otherwise successful policy save. + stored, err := storedAuthConfigExists() + if err != nil { + return errors.Wrap(err, "UpgradeSettings: Could not inspect the stored authentication configuration") + } + if stored { + log.Info("Authentication configuration already migrated; skipping legacy settings upgrade") + return nil + } + //read the current provider var settings []string settings = append(settings, providerSetting) @@ -828,17 +854,13 @@ func UpgradeCase() error { }} // check if GenericObject with key="auth.config" exists - filters := make(map[string]interface{}) - filters["key"] = "auth.config" - authColl, err := PlatformClient.GenericObject.List(&client.ListOpts{ - Filters: filters, - }) + stored, err := storedAuthConfigExists() if err != nil { log.Errorf("Error getting the go 'auth.config', error: %v", err) return err } - if len(authColl.Data) > 0 { + if stored { log.Info("Config stored") return nil } diff --git a/server/config_update_policy_test.go b/server/config_update_policy_test.go index 1815f2c..7945601 100644 --- a/server/config_update_policy_test.go +++ b/server/config_update_policy_test.go @@ -423,6 +423,49 @@ func TestPolicyOnlyUpdateClearsStoredAllowlistWithoutDiscovery(t *testing.T) { } } +func TestUpgradeSettingsDoesNotReplayLegacyMigrationAfterCanonicalConfigExists(t *testing.T) { + genericObjectReads := 0 + settingRequests := 0 + var platformServer *httptest.Server + platformServer = httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Content-Type", "application/json") + switch { + case request.Method == http.MethodGet && request.URL.Path == "/v2-beta": + response.Header().Set("X-API-Schemas", platformServer.URL+"/v2-beta") + _, _ = fmt.Fprintf(response, `{"data":[{"id":"genericObject","type":"schema","pluralName":"genericObjects","collectionMethods":["GET"],"resourceMethods":["GET","PUT"],"links":{"collection":%q}},{"id":"setting","type":"schema","pluralName":"settings","collectionMethods":["GET"],"resourceMethods":["GET","PUT"],"links":{"collection":%q}}]}`, + platformServer.URL+"/v2-beta/genericObjects", platformServer.URL+"/v2-beta/settings") + case request.Method == http.MethodGet && request.URL.Path == "/v2-beta/genericObjects": + genericObjectReads++ + _, _ = fmt.Fprint(response, `{"type":"collection","resourceType":"genericObject","data":[{"id":"1go1","type":"genericObject","key":"auth.config","name":"auth.config","kind":"authConfig","resourceData":{}}]}`) + case strings.HasPrefix(request.URL.Path, "/v2-beta/settings"): + settingRequests++ + http.Error(response, "legacy settings must not be read after migration", http.StatusInternalServerError) + default: + t.Errorf("unexpected platform request %s %s", request.Method, request.URL.String()) + http.Error(response, "unexpected request", http.StatusNotFound) + } + })) + defer platformServer.Close() + + platformClient, err := newPlatformClient(platformServer.URL, "access", "secret") + if err != nil { + t.Fatal(err) + } + previousPlatformClient := PlatformClient + PlatformClient = platformClient + defer func() { PlatformClient = previousPlatformClient }() + + if err := UpgradeSettings(); err != nil { + t.Fatal(err) + } + if genericObjectReads != 1 { + t.Fatalf("expected one canonical config lookup, got %d", genericObjectReads) + } + if settingRequests != 0 { + t.Fatalf("legacy settings were touched %d times after migration", settingRequests) + } +} + func oidcConfigForPolicyTest(enabled bool, accessMode string, identities ...client.Identity) model.AuthConfig { return model.AuthConfig{ Provider: oidcProviderName,