diff --git a/Makefile b/Makefile index da70a3a..d08e729 100644 --- a/Makefile +++ b/Makefile @@ -655,3 +655,30 @@ help: ## Show this help .PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke policy-nexus-check-binding policy-nexus-check-image policy-nexus-render policy-nexus-server-dry-run policy-nexus-deploy policy-nexus-status policy-nexus-smoke policy-nexus-rollback help .PHONY: forgejo-secret-scan-template-deploy + + +##@ Isolated Forgejo source release preparation (no deployment/publication) +FORGEJO_BUILD_WORKSPACE ?= /tmp/railiance-apps-forgejo-release +FORGEJO_SOURCE_ARCHIVE ?= +FORGEJO_GO_ARCHIVE ?= +FORGEJO_NODE_ARCHIVE ?= +FORGEJO_BUILD_GOMODCACHE ?= +FORGEJO_BUILD_GOCACHE_SEED ?= +FORGEJO_BUILD_NPM_CACHE_SEED ?= + +.PHONY: forgejo-release-prepare forgejo-release-acquire-npm forgejo-release-build forgejo-release-verify forgejo-release-container +forgejo-release-prepare: ## Verify archives/patch/locks and prepare a new isolated workspace + /usr/bin/python3 -I tools/forgejo-release-build.py prepare --workspace "$(FORGEJO_BUILD_WORKSPACE)" --source-archive "$(FORGEJO_SOURCE_ARCHIVE)" --go-archive "$(FORGEJO_GO_ARCHIVE)" --node-archive "$(FORGEJO_NODE_ARCHIVE)" --module-cache "$(FORGEJO_BUILD_GOMODCACHE)" $(if $(FORGEJO_BUILD_GOCACHE_SEED),--go-cache-seed "$(FORGEJO_BUILD_GOCACHE_SEED)") $(if $(FORGEJO_BUILD_NPM_CACHE_SEED),--npm-cache-seed "$(FORGEJO_BUILD_NPM_CACHE_SEED)") + +forgejo-release-acquire-npm: ## Explicit public registry dependency acquisition; ignores lifecycle scripts + /usr/bin/python3 -I tools/forgejo-release-build.py acquire-npm --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-build: ## Offline full frontend, bindata and static backend via upstream targets + /usr/bin/python3 -I tools/forgejo-release-build.py build --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-verify: ## Verify all embedded assets, static executable and new CLI shape offline + /usr/bin/python3 -I tools/forgejo-release-build.py verify --workspace "$(FORGEJO_BUILD_WORKSPACE)" + +forgejo-release-container: ## Build/verify a deterministic local rootless OCI archive; never publish or deploy + /usr/bin/python3 -I tools/forgejo-release-build.py container --workspace "$(FORGEJO_BUILD_WORKSPACE)" + diff --git a/build/forgejo/README.md b/build/forgejo/README.md new file mode 100644 index 0000000..ff29075 --- /dev/null +++ b/build/forgejo/README.md @@ -0,0 +1,72 @@ +# Forgejo release preparation (build only) + +The Apps Makefile targets package the independently reviewed seven-path Forgejo +11.0.3 source extension. They never deploy, push, change default values or choose +an operational image. Third-party application source is expanded only into a new +`/tmp` workspace; this repository retains a release input lock and exact reviewed +patch, not a second source-code tree. Upstream source ownership and publication +remain separate owner work. + +Supply explicit public input archives/cache paths to `make forgejo-release-prepare`: +`FORGEJO_SOURCE_ARCHIVE`, `FORGEJO_GO_ARCHIVE`, `FORGEJO_NODE_ARCHIVE`, +`FORGEJO_BUILD_GOMODCACHE`, optionally `FORGEJO_BUILD_GOCACHE_SEED` and +`FORGEJO_BUILD_NPM_CACHE_SEED`. Every archive, patch, source output, dependency +lock and selected host compiler tool is digest-bound by `inputs.lock.json`. +Go's official checksums and Node's official SHASUMS must be independently checked +when acquiring those public archives. The source archive is official Forgejo +v11.0.3; archive PAX commit b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237. + +The workspace must not exist. Existing trials are preserved, never restarted +because an observer timed out. The recipe's `process.json` records the exact +active child PID, phase and command, then completion; logs and receipts stay in +the workspace. Do not run two phases against the same workspace concurrently. + +`make forgejo-release-acquire-npm` explicitly acquires only public registry +packages, with fresh HOME/config paths, integrity-bound lockfile and lifecycle +scripts disabled. With the cache ready, `make forgejo-release-build` reruns +`npm ci --offline --ignore-scripts`, Go module verification, upstream frontend +compilation and upstream generation/static backend targets inside a network +namespace without connectivity. It uses the exact Go/Node distributions, local +compiler inputs, `bindata timetzdata sqlite sqlite_unlock_notify` plus upstream +static target's `netgo osusergo`, explicit Make GOFLAGS for trimpath/buildvcs and +fixed file/directory timestamps. Build tags retain the upstream rootless build +profile. `make forgejo-release-verify` compares every embedded public/options/ +template file to actual generated source bytes, checks the extension CLI and +checks the ELF has no dynamic interpreter. These are local artifact checks, +not application login, DB, native rollout or architecture acceptance. + +For a **local container build** only, first acquire the locked public amd64 +base with an empty Docker client config and the explicit local socket. Then +`make forgejo-release-container` uses that exact digest as a build input and a +minimal context containing only the verified executable and generated Dockerfile. +It directly exports a local OCI archive with `rewrite-timestamp=true`, no-cache, +no image load or push. SOURCE_DATE_EPOCH alone does not fix COPY parent-directory +timestamps. The image epoch is max(source epoch,ceil immutable base created +instant), so the original base layers remain exact while new metadata is stable. +The recipe verifies every OCI blob, original base layer prefix, rootless runtime +configuration, provenance labels and exact final executable. Two no-cache exports +must reproduce the frozen output image and archive digests before claiming +reproducibility. The actual local proof receipts are in the accompanying source +proposal packet, not an admission of the resulting artifact. + +There is no deployment tag, registry publication, RUN directive, privileged +container or Docker host/secret mount. Public base acquisition and a standard +local Docker/buildx daemon supporting OCI export/layer timestamp rewriting are +explicit prerequisites. An unavailable feature is a retained blocker; do not +switch to a privileged builder or claim a successful image from a backend-only +binary. Existing contexts/output archives refuse overwrite. Reconcile an actual +failed export explicitly, retaining original metadata/logs/artifacts; observation +timeouts never justify another build. Older prototype load/save behavior and +its config-versus-manifest-ID issue are retained in the source trial history, +not shipped as a production continuation. + +The static host toolchain is pinned for this Linux amd64 review estate. Broader +cross-host reproducibility or a different architecture/compiler/libc is a new +input revision, not an implicit fallback. The glibc static-linker's optional +SQLite `dlopen` diagnostic is retained; no SQLite-extension runtime compatibility +or production platform admission is inferred from ELF inspection or asset checks. +An accepted owner image/source/backend, authenticated access-engine decision, +private secret-input boundary, type/case/descriptor re-freeze, current operation/ +inverse sessions, durable journal and application/Helm writer exclusion are +separate gates. Helm omission/rollback does not remove persisted OAuth source. +The existing case gate is compatibility code, not a single-PDP exception. diff --git a/build/forgejo/inputs.lock.json b/build/forgejo/inputs.lock.json new file mode 100644 index 0000000..743f819 --- /dev/null +++ b/build/forgejo/inputs.lock.json @@ -0,0 +1,63 @@ +{ + "schema": "railiance-apps.forgejo-release-inputs.v1", + "forgejo_version": "11.0.3", + "source_archive_sha256": "1ddd8c926ca07d40436897971458ace7dcf1d3496a0813ab9e0d869edef2365c", + "source_commit": "b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237", + "patch_sha256": "f2788b3f2bf84dbc0f94e232b6c9614a864b8123c09f9e44703d50d5e1b53257", + "go_archive_sha256": "3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8", + "go_version": "1.24.3", + "node_archive_sha256": "dafe2e8f82cb97de1bd10db9e2ec4c07bbf53389b0799b1e095a918951e78fd4", + "node_version": "22.15.0", + "source_date_epoch": 1752105237, + "release_version": "11.0.3+nk48.f2788b3f", + "tags": "bindata timetzdata sqlite sqlite_unlock_notify", + "runtime_base": "code.forgejo.org/forgejo/forgejo@sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b", + "runtime_index_digest": "sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba", + "platform": "linux/amd64", + "reviewed_outputs": [ + { + "path": "cmd/admin_auth_oauth.go", + "sha256": "1dd03f0bd23889a340a10fcc18d2e3a49270b79056aafd1b3baf01b18559b37f" + }, + { + "path": "routers/web/admin/auths.go", + "sha256": "3498229e06fb9ff52396bcb5e68c0cc2d2ad24e6494533d8717d7c833f3e2923" + }, + { + "path": "routers/web/auth/oauth.go", + "sha256": "081bcb94023d6e27289196bbe518bebba0a8a6082ea1a32d2794c5562075907b" + }, + { + "path": "services/auth/source/oauth2/providers_openid.go", + "sha256": "5dd9b906cce434c774968d57fcc790cb678147345fea4d2e58477ec6eed70bcf" + }, + { + "path": "services/auth/source/oauth2/required_claim_values.go", + "sha256": "29f16d07464bbe12e3f65616b6c1eb60561d04cd8ff89ecbfb56f93dbeaf1cda" + }, + { + "path": "services/auth/source/oauth2/required_claim_values_test.go", + "sha256": "54a8bd97dc51c524a9adf0c6aefb9a19fa152d234b3bb929bf1a90932662d3d0" + }, + { + "path": "services/auth/source/oauth2/source.go", + "sha256": "44e99883c2ec479427b3f72c73353fe33936eed2e5c962b2fa40803576aa5642" + } + ], + "lockfiles": { + "go.mod": "652162a4a95c6d1c349d2a8c0b514def5abd837f001137b77b7b3f4a0f162d00", + "go.sum": "19c305c7e68de9d3f1bc0dda2de09c5d38597f1ee7fa411c7b36b92467ae1eaf", + "package.json": "ab5c54ec7077501b50acf3ab5f1fff4f63a51abe99e720b8ecdc6cc6c78f39fc", + "package-lock.json": "fdd5c2f891311be7c01b1907b418c243ca1f0f951fd4b4ee5b5e931c43493657" + }, + "host_tools": { + "/usr/bin/make": "d78b8f1d099fbcfb6f2f49ab87223b9b68fb3956642f92d6ec6de812e8afa965", + "/usr/bin/gcc": "1b99826121ae6682a634e5efe09bd3e3df58ce58e0b28f849114ab5b89139c26", + "/usr/bin/ld": "e46a122cc3a29feba3b1d0fc2a274b6e46538be016ebe381370c0801bb98b0b8", + "/usr/bin/ar": "534681ac11c18868cfc4fdf98770aa0ba8973eedc90c231e94e6ba96e1a04f27", + "/usr/bin/bwrap": "e318903862396f96de3df57264e0158682b952fd3fb53ac23d876413e7b30f71", + "/usr/lib/x86_64-linux-gnu/libc.a": "a40c7eae7581f977b65eddb81cbc799213a695749e039d966db55bdee4ac9621", + "/usr/lib/x86_64-linux-gnu/libm.a": "420cd3c406f6f8c12deb3d1291146e9c091ee015a7c39eaf2237f01e80d6f0ae" + }, + "runtime_base_config_sha256": "bc5a3df111729582faadebd01d126ba769e2b65d0c62f306f6bc79f4e562452b" +} diff --git a/build/forgejo/required-claim-values.patch b/build/forgejo/required-claim-values.patch new file mode 100644 index 0000000..90d5131 --- /dev/null +++ b/build/forgejo/required-claim-values.patch @@ -0,0 +1,484 @@ +--- a/cmd/admin_auth_oauth.go ++++ b/cmd/admin_auth_oauth.go +@@ -94,6 +94,10 @@ + Name: "required-claim-value", + Value: "", + Usage: "Claim value that has to be set to allow users to login with this source", ++ }, ++ &cli.StringSliceFlag{ ++ Name: "required-claim-values", ++ Usage: "Require any one exact array value from current OpenID Connect UserInfo (cannot combine with required-claim-value)", + }, + &cli.StringFlag{ + Name: "group-claim-name", +@@ -160,6 +164,7 @@ + Scopes: c.StringSlice("scopes"), + RequiredClaimName: c.String("required-claim-name"), + RequiredClaimValue: c.String("required-claim-value"), ++ RequiredClaimValues: c.StringSlice("required-claim-values"), + GroupClaimName: c.String("group-claim-name"), + AdminGroup: c.String("admin-group"), + RestrictedGroup: c.String("restricted-group"), +@@ -177,6 +182,12 @@ + } + + config := parseOAuth2Config(c) ++ if err := oauth2.ValidateRequiredClaimValues(config.RequiredClaimName, config.RequiredClaimValue, config.RequiredClaimValues); err != nil { ++ return err ++ } ++ if config.RequiredClaimValues != nil && config.Provider != "openidConnect" { ++ return errors.New("required-claim-values requires openidConnect") ++ } + if config.Provider == "openidConnect" { + discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL) + if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") { +@@ -246,6 +257,10 @@ + oAuth2Config.RequiredClaimValue = c.String("required-claim-value") + } + ++ if c.IsSet("required-claim-values") { ++ oAuth2Config.RequiredClaimValues = c.StringSlice("required-claim-values") ++ } ++ + if c.IsSet("group-claim-name") { + oAuth2Config.GroupClaimName = c.String("group-claim-name") + } +@@ -293,6 +308,12 @@ + } + + oAuth2Config.CustomURLMapping = customURLMapping ++ if err := oauth2.ValidateRequiredClaimValues(oAuth2Config.RequiredClaimName, oAuth2Config.RequiredClaimValue, oAuth2Config.RequiredClaimValues); err != nil { ++ return err ++ } ++ if oAuth2Config.RequiredClaimValues != nil && oAuth2Config.Provider != "openidConnect" { ++ return errors.New("required-claim-values requires openidConnect") ++ } + source.Cfg = oAuth2Config + + return auth_model.UpdateSource(ctx, source) +--- a/routers/web/admin/auths.go ++++ b/routers/web/admin/auths.go +@@ -322,6 +322,13 @@ + ctx.Data["Source"] = source + ctx.Data["HasTLS"] = source.HasTLS() + ++ // The legacy form has no representation for the opt-in current-UserInfo gate. ++ // Refuse rather than silently dropping it during source reconstruction. ++ if current, ok := source.Cfg.(*oauth2.Source); ok && current.RequiredClaimValues != nil { ++ ctx.RenderWithErr("This OAuth source uses required-claim-values; update it through the reviewed CLI.", tplAuthEdit, form) ++ return ++ } ++ + if ctx.HasError() { + ctx.HTML(http.StatusOK, tplAuthEdit) + return +--- a/routers/web/auth/oauth.go ++++ b/routers/web/auth/oauth.go +@@ -1427,7 +1427,11 @@ + return goth.User{}, err + } + +- if oauth2Source.RequiredClaimName != "" { ++ if oauth2Source.RequiredClaimValues != nil { ++ if oauth2Source.Provider != "openidConnect" || !oauth2.MatchRequiredClaimValues(gothUser.RawData, oauth2Source.RequiredClaimName, oauth2Source.RequiredClaimValue, oauth2Source.RequiredClaimValues) { ++ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} ++ } ++ } else if oauth2Source.RequiredClaimName != "" { + claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName] + if !has { + return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID} +--- a/services/auth/source/oauth2/providers_openid.go ++++ b/services/auth/source/oauth2/providers_openid.go +@@ -4,6 +4,7 @@ + package oauth2 + + import ( ++ "errors" + "html/template" + + "forgejo.org/modules/log" +@@ -43,6 +44,22 @@ + if err != nil { + log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err) + } ++ if err != nil { ++ return nil, err ++ } ++ if source.RequiredClaimValues != nil { ++ if err := ValidateRequiredClaimValues(source.RequiredClaimName, source.RequiredClaimValue, source.RequiredClaimValues); err != nil { ++ return nil, err ++ } ++ if provider.OpenIDConfig == nil || provider.SkipUserInfoRequest { ++ return nil, errors.New("required-claim-values needs current UserInfo") ++ } ++ guarded, err := RequireCurrentUserInfoClaim(provider.Client(), provider.OpenIDConfig.UserInfoEndpoint, source.RequiredClaimName) ++ if err != nil { ++ return nil, err ++ } ++ provider.HTTPClient = guarded ++ } + return provider, err + } + +--- /dev/null ++++ b/services/auth/source/oauth2/required_claim_values.go +@@ -0,0 +1,202 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++ ++package oauth2 ++ ++import ( ++ "bytes" ++ "encoding/json" ++ "errors" ++ "io" ++ "net/http" ++ "net/url" ++ "strings" ++ "unicode" ++ "unicode/utf8" ++) ++ ++const maxClaimValues = 1024 ++const maxUserInfoBytes = 1 << 20 ++ ++// ValidateRequiredClaimValues validates only the opt-in list extension. A nil ++// list retains every legacy single-value behavior. An explicitly empty list ++// fails closed rather than turning a configured gate into claim-presence-only. ++func ValidateRequiredClaimValues(name, legacy string, values []string) error { ++ if values == nil { ++ return nil ++ } ++ if !validClaimLiteral(name) || legacy != "" || len(values) == 0 || len(values) > 64 { ++ return errors.New("invalid or conflicting required-claim-values configuration") ++ } ++ seen := map[string]bool{} ++ for _, value := range values { ++ if !validClaimLiteral(value) || seen[value] { ++ return errors.New("invalid required-claim-values literal") ++ } ++ seen[value] = true ++ } ++ return nil ++} ++ ++func validClaimLiteral(value string) bool { ++ return value != "" && len(value) <= 256 && utf8.ValidString(value) && ++ value == strings.TrimSpace(value) && !strings.ContainsAny(value, "\x00\r\n,") && ++ !strings.ContainsFunc(value, func(r rune) bool { return unicode.IsControl(r) || r == utf8.RuneError }) ++} ++ ++// strictClaimValues accepts only a well-formed claim array. No scalar parsing, ++// coercion, regex or prefix matching is introduced into the new gate. ++func strictClaimValues(raw any) ([]string, bool) { ++ var values []string ++ switch v := raw.(type) { ++ case []string: ++ values = v ++ case []any: ++ values = make([]string, len(v)) ++ for i, value := range v { ++ s, ok := value.(string) ++ if !ok { ++ return nil, false ++ } ++ values[i] = s ++ } ++ default: ++ return nil, false ++ } ++ if len(values) > maxClaimValues { ++ return nil, false ++ } ++ for _, value := range values { ++ if !validClaimLiteral(value) { ++ return nil, false ++ } ++ } ++ return values, true ++} ++ ++// MatchRequiredClaimValues evaluates the new consumer gate against current ++// authenticated provider data; it does not mutate or manufacture issuer claims. ++func MatchRequiredClaimValues(claims map[string]any, name, legacy string, required []string) bool { ++ if required == nil || ValidateRequiredClaimValues(name, legacy, required) != nil { ++ return false ++ } ++ raw, present := claims[name] ++ if !present { ++ return false ++ } ++ values, ok := strictClaimValues(raw) ++ if !ok { ++ return false ++ } ++ for _, value := range values { ++ for _, wanted := range required { ++ if value == wanted { ++ return true ++ } ++ } ++ } ++ return false ++} ++ ++// RequireCurrentUserInfoClaim preserves the existing HTTP client settings and ++// adds a strict response guard for this opt-in source only. A current UserInfo ++// array must overwrite any stale ID-token claim. Missing/invalid data and HTTP ++// failures cannot fall back to the ID-token map in Goth's merging code. ++func RequireCurrentUserInfoClaim(client *http.Client, endpoint, claim string) (*http.Client, error) { ++ u, err := url.Parse(endpoint) ++ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || !validClaimLiteral(claim) { ++ return nil, errors.New("required-claim-values needs an HTTPS UserInfo endpoint") ++ } ++ if client == nil { ++ client = http.DefaultClient ++ } ++ result := *client ++ base := result.Transport ++ if base == nil { ++ base = http.DefaultTransport ++ } ++ result.Transport = ¤tClaimTransport{base: base, endpoint: endpoint, claim: claim} ++ // Redirects could evade endpoint matching or send a bearer to another origin. ++ // They are unsupported for the new guarded provider; legacy clients are intact. ++ result.CheckRedirect = func(*http.Request, []*http.Request) error { ++ return errors.New("guarded OAuth redirects are not supported") ++ } ++ return &result, nil ++} ++ ++type currentClaimTransport struct { ++ base http.RoundTripper ++ endpoint, claim string ++} ++ ++func (t *currentClaimTransport) RoundTrip(req *http.Request) (*http.Response, error) { ++ response, err := t.base.RoundTrip(req) ++ if req.URL.String() != t.endpoint { ++ return response, err ++ } ++ if err != nil { ++ if response != nil && response.Body != nil { ++ response.Body.Close() ++ } ++ return nil, errors.New("current UserInfo lookup failed") ++ } ++ if response == nil || response.Body == nil { ++ return nil, errors.New("current UserInfo response missing") ++ } ++ defer response.Body.Close() ++ if req.Method != http.MethodGet || response.StatusCode != http.StatusOK { ++ return nil, errors.New("current UserInfo refused") ++ } ++ body, err := io.ReadAll(io.LimitReader(response.Body, maxUserInfoBytes+1)) ++ if err != nil || len(body) > maxUserInfoBytes || !utf8.Valid(body) { ++ return nil, errors.New("current UserInfo response invalid") ++ } ++ // Reject duplicate top-level keys before Unmarshal can hide a conflicting ++ // groups or subject claim. json.RawMessage permits unrelated normal claims. ++ decoder := json.NewDecoder(bytes.NewReader(body)) ++ token, err := decoder.Token() ++ if err != nil || token != json.Delim('{') { ++ return nil, errors.New("current UserInfo object required") ++ } ++ claims := map[string]json.RawMessage{} ++ for decoder.More() { ++ keyToken, err := decoder.Token() ++ if err != nil { ++ return nil, errors.New("current UserInfo key invalid") ++ } ++ key, ok := keyToken.(string) ++ if !ok { ++ return nil, errors.New("current UserInfo key invalid") ++ } ++ if _, duplicate := claims[key]; duplicate { ++ return nil, errors.New("current UserInfo duplicate claim") ++ } ++ var value json.RawMessage ++ if decoder.Decode(&value) != nil { ++ return nil, errors.New("current UserInfo value invalid") ++ } ++ claims[key] = value ++ } ++ if token, err = decoder.Token(); err != nil || token != json.Delim('}') { ++ return nil, errors.New("current UserInfo object invalid") ++ } ++ var trailing any ++ if decoder.Decode(&trailing) != io.EOF { ++ return nil, errors.New("current UserInfo trailing data") ++ } ++ raw, present := claims[t.claim] ++ if !present { ++ return nil, errors.New("current UserInfo required claim missing") ++ } ++ var value any ++ if json.Unmarshal(raw, &value) != nil { ++ return nil, errors.New("current UserInfo required claim invalid") ++ } ++ if _, ok := strictClaimValues(value); !ok { ++ return nil, errors.New("current UserInfo required claim array invalid") ++ } ++ // Return the original public response bytes to Goth. Subject equality and ++ // audience/issuer checks remain in its existing authenticated provider path. ++ response.Body = io.NopCloser(bytes.NewReader(body)) ++ return response, nil ++} +--- /dev/null ++++ b/services/auth/source/oauth2/required_claim_values_test.go +@@ -0,0 +1,144 @@ ++// Copyright 2026 The Forgejo Authors. All rights reserved. ++// SPDX-License-Identifier: MIT ++package oauth2 ++ ++import ( ++ "errors" ++ "io" ++ "net/http" ++ "reflect" ++ "strings" ++ "testing" ++) ++ ++var caseValues = []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"} ++ ++type roundTripFunc func(*http.Request) (*http.Response, error) ++ ++func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } ++ ++func mockUserInfo(body string, status int) *http.Client { ++ return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { ++ return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil ++ })} ++} ++ ++func TestRequiredClaimExactAnyOfAndNoMutation(t *testing.T) { ++ for _, group := range caseValues { ++ groups := []any{group, "unrelated"} ++ claims := map[string]any{"groups": groups, "other": "preserved"} ++ if !MatchRequiredClaimValues(claims, "groups", "", caseValues) { ++ t.Fatal("individual allowed group rejected") ++ } ++ if !reflect.DeepEqual(claims, map[string]any{"groups": groups, "other": "preserved"}) { ++ t.Fatal("claims mutated") ++ } ++ } ++ if !MatchRequiredClaimValues(map[string]any{"groups": append([]string(nil), caseValues...)}, "groups", "", caseValues) { ++ t.Fatal("multiple allowed groups rejected") ++ } ++ for _, raw := range []any{nil, true, 1, "forge-admins", "forge-admins,forge-coulomb-owners", []any{"forge-admins", 1}, []any{"forge-admins", nil}, []string{}, []any{}, []string{"forge-admins-lookalike"}, []string{" Forge-admins"}, []string{"forge-admins\n"}, []string{"Forge-admins"}, []string{"forge-admins,other"}, []string{"forge-admins", ""}} { ++ if MatchRequiredClaimValues(map[string]any{"groups": raw}, "groups", "", caseValues) { ++ t.Fatal("malformed/nonmatching claim accepted") ++ } ++ } ++ if MatchRequiredClaimValues(map[string]any{}, "groups", "", caseValues) { ++ t.Fatal("absent claim accepted") ++ } ++ if MatchRequiredClaimValues(map[string]any{"unrelated": []string{"forge-admins"}}, "groups", "", caseValues) { ++ t.Fatal("unrelated claim used") ++ } ++} ++ ++func TestRequiredClaimConfigurationRefusalsAndLegacyIsolation(t *testing.T) { ++ if ValidateRequiredClaimValues("", "old arbitrary literal", nil) != nil { ++ t.Fatal("legacy config changed") ++ } ++ for _, cfg := range []struct { ++ name, legacy string ++ values []string ++ }{ ++ {"groups", "", []string{}}, {"groups", "", []string{""}}, {"groups", "old", caseValues}, {"", "", caseValues}, ++ {"groups", "", []string{"a", "a"}}, {"groups", "", []string{"a,b"}}, {"groups", "", []string{" a"}}, ++ } { ++ if ValidateRequiredClaimValues(cfg.name, cfg.legacy, cfg.values) == nil { ++ t.Fatal("invalid config accepted") ++ } ++ } ++ if ValidateRequiredClaimValues("groups", "", caseValues) != nil { ++ t.Fatal("case config invalid") ++ } ++} ++ ++func TestCurrentUserInfoGuardRejectsMissingMalformedAndUnavailable(t *testing.T) { ++ for _, body := range []string{ ++ `{}`, `{"sub":"alice"}`, `{"groups":null}`, `{"groups":true}`, `{"groups":"forge-admins"}`, ++ `{"groups":["forge-admins",null]}`, `{"groups":["forge-admins",1]}`, ++ `{"groups":["forge-admins"],"groups":[]}`, `{"groups":[]} {}`, `[]`, `{"groups":[]`, ++ `{"groups":["forge-admins",""]}`, strings.Repeat(" ", maxUserInfoBytes+1), ++ } { ++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups") ++ if err != nil { ++ t.Fatal(err) ++ } ++ response, err := client.Get("https://issuer.example/userinfo") ++ if err == nil { ++ response.Body.Close() ++ t.Fatal("invalid current claim did not fail closed") ++ } ++ } ++ client, _ := RequireCurrentUserInfoClaim(mockUserInfo(`{"groups":[]}`, 503), "https://issuer.example/userinfo", "groups") ++ if response, err := client.Get("https://issuer.example/userinfo"); err == nil { ++ response.Body.Close() ++ t.Fatal("non200 accepted") ++ } ++ broken := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("private backend error") })} ++ client, _ = RequireCurrentUserInfoClaim(broken, "https://issuer.example/userinfo", "groups") ++ if _, err := client.Get("https://issuer.example/userinfo"); err == nil || strings.Contains(err.Error(), "private backend") { ++ t.Fatal("lookup failure not contained") ++ } ++} ++ ++func TestCurrentUserInfoEmptyArrayOverwritesAndGroupRemoval(t *testing.T) { ++ for _, body := range []string{`{"sub":"alice","groups":[]}`, `{"sub":"alice","groups":["forge-coulomb-readers"]}`} { ++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups") ++ if err != nil { ++ t.Fatal(err) ++ } ++ response, err := client.Get("https://issuer.example/userinfo") ++ if err != nil { ++ t.Fatal(err) ++ } ++ b, _ := io.ReadAll(response.Body) ++ response.Body.Close() ++ if string(b) != body { ++ t.Fatal("response changed") ++ } ++ } ++} ++ ++func TestCurrentUserInfoGuardIsOptInAndRedirectsRefuse(t *testing.T) { ++ original := mockUserInfo(`{"unrelated":true}`, 200) ++ guarded, err := RequireCurrentUserInfoClaim(original, "https://issuer.example/userinfo", "groups") ++ if err != nil { ++ t.Fatal(err) ++ } ++ response, err := original.Get("https://issuer.example/userinfo") ++ if err != nil { ++ t.Fatal("original client mutated") ++ } ++ response.Body.Close() ++ response, err = guarded.Get("https://issuer.example/unrelated") ++ if err != nil { ++ t.Fatal("unrelated endpoint changed") ++ } ++ response.Body.Close() ++ if guarded.CheckRedirect == nil || guarded.CheckRedirect(nil, nil) == nil { ++ t.Fatal("redirect can bypass guard") ++ } ++ for _, endpoint := range []string{"", "http://issuer.example/userinfo", "https://user:pass@issuer.example/userinfo", "https://issuer.example/userinfo#fragment"} { ++ if _, err := RequireCurrentUserInfoClaim(original, endpoint, "groups"); err == nil { ++ t.Fatal("invalid endpoint accepted") ++ } ++ } ++} +--- a/services/auth/source/oauth2/source.go ++++ b/services/auth/source/oauth2/source.go +@@ -22,6 +22,7 @@ + Scopes []string + AttributeSSHPublicKey string + RequiredClaimName string ++ RequiredClaimValues []string `json:",omitempty"` + RequiredClaimValue string + GroupClaimName string + AdminGroup string diff --git a/build/forgejo/runtime-base-config.json b/build/forgejo/runtime-base-config.json new file mode 100644 index 0000000..e2ad4cd --- /dev/null +++ b/build/forgejo/runtime-base-config.json @@ -0,0 +1 @@ +{"architecture":"amd64","config":{"User":"1000:1000","ExposedPorts":{"2222/tcp":{},"3000/tcp":{}},"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","GITEA_WORK_DIR=/var/lib/gitea","GITEA_CUSTOM=/var/lib/gitea/custom","GITEA_TEMP=/tmp/gitea","TMPDIR=/tmp/gitea","GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","HOME=/var/lib/gitea/git"],"Entrypoint":["/usr/bin/dumb-init","--","/usr/local/bin/docker-entrypoint.sh"],"Volumes":{"/etc/gitea":{},"/var/lib/gitea":{}},"WorkingDir":"/var/lib/gitea","Labels":{"maintainer":"contact@forgejo.org","org.opencontainers.image.authors":"Forgejo","org.opencontainers.image.description":"Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","org.opencontainers.image.documentation":"https://forgejo.org/download/#container-image","org.opencontainers.image.licenses":"GPL-3.0-or-later","org.opencontainers.image.source":"https://codeberg.org/forgejo/forgejo","org.opencontainers.image.title":"Forgejo. Beyond coding. We forge.","org.opencontainers.image.url":"https://forgejo.org","org.opencontainers.image.vendor":"Forgejo","org.opencontainers.image.version":"11.0.3"},"ArgsEscaped":true},"created":"2025-07-10T04:12:21.034425519Z","history":[{"created":"2025-02-14T03:28:36Z","created_by":"ADD alpine-minirootfs-3.21.3-x86_64.tar.gz / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-02-14T03:28:36Z","created_by":"CMD [\"/bin/sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"ARG RELEASE_VERSION=11.0.3","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"LABEL maintainer=contact@forgejo.org org.opencontainers.image.authors=Forgejo org.opencontainers.image.url=https://forgejo.org org.opencontainers.image.documentation=https://forgejo.org/download/#container-image org.opencontainers.image.source=https://codeberg.org/forgejo/forgejo org.opencontainers.image.version=11.0.3 org.opencontainers.image.vendor=Forgejo org.opencontainers.image.licenses=GPL-3.0-or-later org.opencontainers.image.title=Forgejo. Beyond coding. We forge. org.opencontainers.image.description=Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"EXPOSE map[2222/tcp:{} 3000/tcp:{}]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c apk --no-cache add bash ca-certificates dumb-init gettext git curl gnupg openssh-client \u0026\u0026 rm -rf /var/cache/apk/* # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.524887902Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c addgroup -S -g 1000 git \u0026\u0026 adduser -S -H -D -h /var/lib/gitea/git -s /bin/bash -u 1000 -G git git # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.622516492Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c mkdir -p /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.719074007Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c chown git:git /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.677652029Z","created_by":"COPY /tmp/local / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.772570625Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c cd /usr/local/bin ; ln -s gitea forgejo # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.904591549Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/gitea /app/gitea/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.992814487Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c ln -s /app/gitea/gitea /app/gitea/forgejo-cli # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.019203454Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/environment-to-ini /usr/local/bin/environment-to-ini # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"COPY /go/src/forgejo.org/contrib/autocompletion/bash_autocomplete /etc/profile.d/gitea_bash_autocomplete.sh # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"USER 1000:1000","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_WORK_DIR=/var/lib/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_CUSTOM=/var/lib/gitea/custom","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_TEMP=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV TMPDIR=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV HOME=/var/lib/gitea/git","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"VOLUME [/var/lib/gitea /etc/gitea]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"WORKDIR /var/lib/gitea","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"ENTRYPOINT [\"/usr/bin/dumb-init\" \"--\" \"/usr/local/bin/docker-entrypoint.sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"CMD []","comment":"buildkit.dockerfile.v0","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:08000c18d16dadf9553d747a58cf44023423a9ab010aab96cf263d2216b8b350","sha256:cf760eaa10b0319495870aab29b322acf75ba0755f51f1f1a23bcf636243025b","sha256:c6d4eb3ac8d434116be911d6c5dbfbbf600b5f8db34d8564f42f916516bc8bee","sha256:4bbca535f88127e38de4d207fc606ff1f09612b9b45f4a583b39781d2a45ff91","sha256:4d7e7dd4baa79b36260ede93d34c7d57e7370d714e129c374aec60371f751977","sha256:b422e6485954feb40fe6d57beb145775bca592f0fe068477845a7e843e8d8214","sha256:51a112d24ca275b0dd383e1a9c1f725c0967f56aff9ba393d87db1c208369873","sha256:3129f4769ae273912802802ef504d87add6e9181916fb9e74ee609375c474789","sha256:cc72664d7cdf5bcebdac72c859627611fd05733067b566481d180a398b30d16b","sha256:fa9096e760abba41eb596e3d47f4bb9e9a032e906deb4ffccafa07aa6619ca7b","sha256:2c5411c383707f038d6f60e17502f73fb92254dce8d8b748f432287d12b9a633","sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"]}} \ No newline at end of file diff --git a/tools/forgejo-release-build.py b/tools/forgejo-release-build.py new file mode 100644 index 0000000..08524d2 --- /dev/null +++ b/tools/forgejo-release-build.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Build-only public Forgejo owner recipe; no deployment or publication code.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tarfile +import time +import math +import datetime + +OWNER=Path(__file__).resolve().parents[1] +INPUTS=OWNER/'build/forgejo' +LOCK=json.loads((INPUTS/'inputs.lock.json').read_text()) + +def digest(path): + h=hashlib.sha256() + with Path(path).open('rb') as f: + for block in iter(lambda:f.read(1024*1024),b''):h.update(block) + return h.hexdigest() + +def save(path,data): + Path(path).write_text(json.dumps(data,indent=2,sort_keys=True)+'\n') + +def check(path,expected): + if digest(path)!=expected:raise ValueError('frozen public input digest differs: '+str(path)) + +def base_env(work): + return {'PATH':str(work/'node-v22.15.0-linux-x64/bin')+':'+str(work/'go/bin')+':/usr/bin:/bin', + 'HOME':str(work/'home'),'GOTOOLCHAIN':'local','GOPROXY':'off','GOSUMDB':'off','GOVCS':'*:off', + 'GOPATH':str(work/'gopath'),'GOMODCACHE':json.loads((work/'prepared.json').read_text())['module_cache'], + 'GOCACHE':str(work/'gocache'),'CGO_ENABLED':'1','GOMAXPROCS':'4', + 'GOFLAGS':'-trimpath -buildvcs=false -p=4','SOURCE_DATE_EPOCH':str(LOCK['source_date_epoch']), + 'TZ':'UTC','LC_ALL':'C','NODE_OPTIONS':'--max-old-space-size=4096', + 'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0', + 'NPM_CONFIG_USERCONFIG':str(work/'empty.npmrc'),'NPM_CONFIG_GLOBALCONFIG':str(work/'empty-global.npmrc'), + 'NPM_CONFIG_CACHE':str(work/'npm-cache'),'NPM_CONFIG_REGISTRY':'https://registry.npmjs.org', + 'NPM_CONFIG_OFFLINE':'true','NPM_CONFIG_AUDIT':'false','NPM_CONFIG_FUND':'false', + 'NPM_CONFIG_UPDATE_NOTIFIER':'false','PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD':'1'} + +def sandbox(work,env): + cache=Path(env['GOMODCACHE']) + return ['bwrap','--unshare-net','--unshare-pid','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib', + '--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev', + '--tmpfs','/tmp','--bind',str(work),str(work),'--ro-bind',str(cache),str(cache), + '--chdir',str(work/'source'),'--','env','-i']+[k+'='+v for k,v in env.items()] + +def run(work,name,args,env=None,offline=False,cwd=None): + actual=sandbox(work,env)+args if offline else args + start=time.time() + with (work/(name+'.log')).open('w') as out: + proc=subprocess.Popen(actual,cwd=cwd or work,env={"PATH":"/usr/bin:/bin","HOME":str(work/"home")} if offline else env,stdout=out,stderr=subprocess.STDOUT) + save(work/'process.json',{'phase':name,'pid':proc.pid,'driver_pid':os.getpid(),'argv':args,'state':'running'}) + code=proc.wait() + result={'phase':name,'argv':args,'returncode':code,'seconds':round(time.time()-start,2)} + save(work/(name+'-result.json'),result);save(work/'process.json',dict(result,state='complete',pid=None,driver_pid=None)) + print(json.dumps(result),flush=True) + if code:raise RuntimeError('phase failed; retained log: '+str(work/(name+'.log'))) + return result + +def checked_inputs(work): + for n,h in LOCK['host_tools'].items():check(n,h) + for n,h in LOCK['lockfiles'].items():check(work/'source'/n,h) + for x in LOCK['reviewed_outputs']:check(work/'source'/x['path'],x['sha256']) + +def prepare(a,work): + if work.exists():raise ValueError('workspace must be new; existing trial is never restarted or overwritten') + for n,key in [('source_archive','source_archive_sha256'),('go_archive','go_archive_sha256'),('node_archive','node_archive_sha256')]: + if not getattr(a,n):raise ValueError('explicit '+n+' required') + check(getattr(a,n),LOCK[key]) + if not a.module_cache:raise ValueError('explicit public module cache required') + cache=Path(a.module_cache).resolve();assert cache.is_dir() + check(INPUTS/'required-claim-values.patch',LOCK['patch_sha256']) + for n,h in LOCK['host_tools'].items():check(n,h) + work.mkdir(parents=True);(work/'home').mkdir() + with tarfile.open(a.source_archive) as t: + # The official archive is digest-bound before any member is extracted. + parent=work/'unpacked';parent.mkdir();t.extractall(parent,filter='data') + folders=list(parent.iterdir());assert len(folders)==1 and folders[0].is_dir();folders[0].rename(work/'source');parent.rmdir() + for n in ['go_archive','node_archive']: + with tarfile.open(getattr(a,n)) as t:t.extractall(work,filter='data') + for n in ['empty.npmrc','empty-global.npmrc']:(work/n).write_text('') + if a.go_cache_seed:shutil.copytree(Path(a.go_cache_seed).resolve(),work/'gocache') + if a.npm_cache_seed:shutil.copytree(Path(a.npm_cache_seed).resolve(),work/'npm-cache') + subprocess.run(['/usr/bin/git','apply','--check',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + subprocess.run(['/usr/bin/git','apply',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'}) + save(work/'prepared.json',{'module_cache':str(cache),'lock_sha256':digest(INPUTS/'inputs.lock.json'),'public_archives_verified':True}) + checked_inputs(work) + packages=json.loads((work/'source/package-lock.json').read_text())['packages'] + for name,package in packages.items(): + if not name:continue + if not package.get('resolved','').startswith('https://registry.npmjs.org/') or not package.get('integrity','').startswith('sha512-'): + raise ValueError('non-public/non-integrity-bound npm package: '+name) + print(json.dumps({'prepared':True,'lockfile_npm_packages':len(packages)-1})) + +def normalize(folder): + epoch=LOCK['source_date_epoch'] + for root,dirs,files in os.walk(folder): + dirs[:]=[x for x in dirs if x not in ('node_modules','.git')] + for n in files: + p=Path(root)/n + if not p.is_symlink():os.utime(p,(epoch,epoch)) + os.utime(root,(epoch,epoch)) + +def build(work): + checked_inputs(work);env=base_env(work) + run(work,'verify-go-modules',['go','mod','verify'],env,True) + run(work,'npm-offline-ci',['npm','ci','--offline','--ignore-scripts','--no-audit','--no-fund'],env,True) + normalize(work/'source') + version=['GOFLAGS=-trimpath -buildvcs=false -p=4','RELEASE_VERSION='+LOCK['release_version'],'FORGEJO_VERSION='+LOCK['release_version'],'FORGEJO_VERSION_API='+LOCK['release_version']] + run(work,'frontend',['make']+version+['frontend'],env,True) + for n in ['public','options','templates','modules/migration/schemas']:normalize(work/'source'/n) + run(work,'embedded-and-static-backend',['make']+version+['TAGS='+LOCK['tags'],'go-check','generate-backend','static-executable'],env,True) + checked_inputs(work) + save(work/'build.json',{'binary_sha256':digest(work/'source/gitea'),'source_locks_preserved':True,'frontend_built':True,'tags':LOCK['tags'],'image_deployment_selected':False}) + +def verify(work): + checked_inputs(work);binary=work/'source/gitea' + target=work/'embedded-extracted' + if target.exists():raise ValueError('retain prior verification; extraction target already exists') + target.mkdir() + # No source, asset tree, host /usr, libraries, configuration or HOME is + # exposed to the executable. A filesystem fallback cannot pass this proof. + bare=['bwrap','--unshare-net','--unshare-pid','--proc','/proc','--dev','/dev', + '--tmpfs','/tmp','--dir','/app','--ro-bind',str(binary),'/app/forgejo', + '--bind',str(target),'/results','--chdir','/tmp','--clearenv'] + for k,v in {'PATH':'/nonexistent','HOME':'/tmp/home','USER':'builder','USERNAME':'builder', + 'GITEA_WORK_DIR':'/tmp/work','GITEA_CUSTOM':'/tmp/custom', + 'GITEA_APP_INI':'/tmp/absent-app.ini','TZ':'UTC','LC_ALL':'C'}.items(): + bare+=['--setenv',k,v] + bare+=['--','/app/forgejo'] + outer={'PATH':'/usr/bin:/bin','HOME':str(work/'home')} + run(work,'binary-version',bare+['--version'],outer) + run(work,'new-cli-help',bare+['admin','auth','add-oauth','--help'],outer) + assert '--required-claim-values' in (work/'new-cli-help.log').read_text() + run(work,'embedded-extract',bare+['embedded','extract','--include-vendored','--destination','/results','**'],outer) + records={} + for folder in ['public','options','templates']: + for f in sorted((work/'source'/folder).rglob('*')): + if not f.is_file():continue + n=str(f.relative_to(work/'source'));q=target/n + if not q.is_file() or digest(f)!=digest(q):raise ValueError('embedded asset missing/different: '+n) + records[n]=digest(f) + assert 'public/assets/js/index.js' in records and 'public/assets/css/index.css' in records + assert 'public/assets/licenses.txt' in records + save(work/'embedded-files.json',records) + readelf=subprocess.run(['/usr/bin/readelf','-l',str(binary)],capture_output=True,text=True,check=True,env={'PATH':'/usr/bin:/bin'}) + assert 'INTERP' not in readelf.stdout + save(work/'verification.json',{'binary_sha256':digest(binary),'embedded_files':len(records), + 'embedded_file_manifest_sha256':digest(work/'embedded-files.json'),'all_public_options_templates_exact':True, + 'no_source_assets_libraries_or_host_config_mounted':True, + 'no_dynamic_interpreter':True,'new_cli_flag_present':True,'network':'bwrap --unshare-net', + 'source_locks_preserved':True,'architecture_approval':False,'native_admission':False}) + print((work/'verification.json').read_text()) + +def container(work): + verified=json.loads((work/'verification.json').read_text());check(work/'source/gitea',verified['binary_sha256']) + context=work/'container-context' + if context.exists():raise ValueError('container trial already exists; retain evidence before an explicit revised trial') + archive_path=work/'image.oci.tar' + if archive_path.exists():raise ValueError('retain existing OCI artifact; never overwrite it') + base_bytes=(INPUTS/'runtime-base-config.json').read_bytes() + assert hashlib.sha256(base_bytes).hexdigest()==LOCK['runtime_base_config_sha256'] + original=json.loads(base_bytes) + epoch=max(LOCK['source_date_epoch'],math.ceil(datetime.datetime.fromisoformat(original['created'].replace('Z','+00:00')).timestamp())) + context.mkdir();shutil.copyfile(work/'source/gitea',context/'gitea') + os.utime(context/'gitea',(LOCK['source_date_epoch'],)*2) + (context/'Dockerfile').write_text('FROM '+LOCK['runtime_base']+'\nARG SOURCE_DATE_EPOCH\nLABEL org.opencontainers.image.version="'+LOCK['release_version']+'" org.opencontainers.image.revision="'+LOCK['source_commit']+'" org.railiance.source.patch.sha256="'+LOCK['patch_sha256']+'"\nCOPY --chown=0:0 --chmod=755 gitea /app/gitea/gitea\n') + os.utime(context/'Dockerfile',(LOCK['source_date_epoch'],)*2) + config=work/'docker-config';config.mkdir(exist_ok=True) + env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'DOCKER_CONFIG':str(config),'DOCKER_HOST':'unix:///var/run/docker.sock'} + # SOURCE_DATE_EPOCH alone does not normalise COPY parent-directory mtimes. + # OCI output permits layer rewriting without the Docker-store unpack conflict. + # The epoch never predates the immutable base, preserving its exact layer prefix. + run(work,'container-build',['docker','buildx','build','--output','type=oci,dest='+str(archive_path)+',rewrite-timestamp=true', + '--no-cache','--platform',LOCK['platform'],'--pull=false','--network=none','--provenance=false','--sbom=false', + '--metadata-file',str(work/'image-build-metadata.json'),'--build-arg','SOURCE_DATE_EPOCH='+str(epoch),str(context)],env) + metadata=json.loads((work/'image-build-metadata.json').read_text()) + with tarfile.open(archive_path) as archive: + index_bytes=archive.extractfile('index.json').read();index=json.loads(index_bytes) + assert len(index['manifests'])==1 + descriptor=index['manifests'][0] + assert descriptor['platform']=={'architecture':'amd64','os':'linux'} + assert descriptor['digest']==metadata['containerimage.digest'] + def blob(d): + value=archive.extractfile('blobs/sha256/'+d.split(':')[1]).read() + assert 'sha256:'+hashlib.sha256(value).hexdigest()==d + return value + manifest=json.loads(blob(descriptor['digest'])) + config_digest=manifest['config']['digest'];config=json.loads(blob(config_digest)) + assert config_digest==metadata['containerimage.config.digest'] + for layer in manifest['layers']:blob(layer['digest']) + assert config['rootfs']['diff_ids'][:-1]==original['rootfs']['diff_ids'] + assert config['architecture']=='amd64' and config['os']=='linux' + for key in ['User','Entrypoint','Cmd','Env','WorkingDir','Volumes']: + assert config['config'].get(key)==original['config'].get(key),key + labels=dict(original['config'].get('Labels',{})) + labels.update({'org.opencontainers.image.version':LOCK['release_version'], + 'org.opencontainers.image.revision':LOCK['source_commit'],'org.railiance.source.patch.sha256':LOCK['patch_sha256']}) + assert config['config'].get('Labels')==labels + assert config['config']['User']=='1000:1000' + expected_created=datetime.datetime.fromtimestamp(epoch,datetime.timezone.utc).isoformat(timespec='seconds').replace('+00:00','Z') + assert config['created']==expected_created + found=False + last='blobs/sha256/'+manifest['layers'][-1]['digest'].split(':')[1] + with tarfile.open(fileobj=archive.extractfile(last),mode='r|*') as layer: + for member in layer: + name=member.name.lstrip('./');assert name in ('app','app/gitea','app/gitea/gitea') + if name=='app/gitea/gitea': + h=hashlib.sha256();stream=layer.extractfile(member) + for block in iter(lambda:stream.read(1024*1024),b''):h.update(block) + assert h.hexdigest()==verified['binary_sha256'];found=True + assert found + save(work/'container-result.json',{'image_manifest_digest':descriptor['digest'],'config_digest':config_digest, + 'oci_index_sha256':hashlib.sha256(index_bytes).hexdigest(),'archive_sha256':digest(archive_path), + 'artifact_path':str(archive_path),'source_epoch':LOCK['source_date_epoch'],'container_epoch':epoch, + 'all_base_layers_and_rootless_runtime_config_preserved':True,'all_oci_blob_digests_verified':True, + 'final_layer_binary_exact':True,'registry_publication':False,'deployment_tag':None, + 'host_or_secret_mounts':False,'privileged_containers':False,'local_runtime_image_loaded':False}) + print((work/'container-result.json').read_text()) + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('phase',choices=['prepare','acquire-npm','build','verify','container']) + parser.add_argument('--workspace',required=True) + for n in ['source-archive','go-archive','node-archive','module-cache','go-cache-seed','npm-cache-seed']:parser.add_argument('--'+n) + a=parser.parse_args();work=Path(a.workspace).resolve() + if not str(work).startswith('/tmp/'):raise ValueError('isolated build workspace must be under /tmp') + if a.phase=='prepare':return prepare(a,work) + assert json.loads((work/'prepared.json').read_text())['lock_sha256']==digest(INPUTS/'inputs.lock.json') + if a.phase=='acquire-npm': + checked_inputs(work);env=base_env(work);env['NPM_CONFIG_OFFLINE']='false' + return run(work,'npm-public-acquire',['npm','ci','--ignore-scripts','--no-audit','--no-fund'],env,cwd=work/'source') + {'build':build,'verify':verify,'container':container}[a.phase](work) + +if __name__=='__main__':main() diff --git a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md index a1615e3..6abc696 100644 --- a/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md +++ b/workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md @@ -9,7 +9,7 @@ flavor: implementation owner: claude-code topic_slug: railiance created: "2026-10-08" -updated: "2026-10-08" +updated: "2026-10-18" depends_on: - KEY-WP-0038 related_repos: @@ -168,3 +168,96 @@ adapters' checks with no value-bearing keys. The k3s `registries.yaml` on railiance01 still holds a login for the frozen Gitea host `92.205.130.254` (ORGREF-WP-0002-T02). Coordinate its removal with the node owner when ORGREF-WP-0002-T06 adds the forge registry login, in the same change. + + +## T06 — Package the reviewed consumer source through the Apps build path + +```task +id: RAPPS-WP-0016-T06 +status: done +priority: high +state_hub_task_id: "08ab8921-7801-5309-b84b-bc2a3ceea1e4" +``` + +Completed 2026-10-18, source build tooling only. The existing Apps Makefile now +has explicit prepare, public-dependency acquisition, offline build, artifact +verification and local OCI export targets for the pinned official Forgejo11.0.3 +source plus reviewed multi-value claim patch. Source/toolchains/locks/compiler +inputs/base image are fixed. Third-party application source stays in a new /tmp +workspace; Apps holds the release input lock, patch and build recipe. + +The original build profile produced identical frontend assets, bindata sources +and complete static executable in two workspaces, and identical local OCI +archives in two no-cache exports. Independent review then found host process +argv visibility through /proc (APPSBUILD-R1-F01). The revised recipe adds a +private PID namespace; exact source and synthetic canary regression are separately +reviewed. The original full builds are historical r1-profile evidence; no full +r2 build is claimed. Original packet/review and failure history remain in +Custodian apps-forgejo-release-build-r1 and apps-release-independent-review-r1; +the correction is apps-forgejo-release-build-r2. This is coordinator source +integration after independent technical review, not formal owner assent or image +admission. Helm values, deployed image, local sign-in and visibility are unchanged. +NK-WP-0048-T39 remains progress for the full release acceptance scope. + +## T07 — Keep the client secret out of process arguments + +```task +id: RAPPS-WP-0016-T07 +status: progress +priority: high +state_hub_task_id: "786d6584-6db1-5a10-9324-d7b94db0df94" +``` + +Implement and independently review a real Forgejo add/update --secret-file path +and the actual chart helper/mount adaptation. Preserve the existing CLIENT_SECRET +key and custody route; use no agent-value argument or environment fallback. Test +bounded stable nofollow file reads, exact existing minted-secret encoding, +mutual exclusion/update preservation, fixed errors and actual Helm rendering. +Private mount identity/reader set and new package/type revisions need explicit +owner/runtime admission. The in-progress isolated candidate is Custodian +forgejo-private-input-r1; source completion is distinct from T02 native delivery. + +## T08 — Provide a transactional application-source inverse and durable recovery + +```task +id: RAPPS-WP-0016-T08 +status: progress +priority: high +state_hub_task_id: "4ab8a5e7-ab32-5ee2-aaf2-6da246d1b0c8" +``` + +Implement actual owner database primitives that record operation intent/outcome +in the same durable database, compare complete raw persisted before/post/current +source state privately, and serialize conflicting application writers at the DB +boundary. Never emit stored credential/config bytes or delete/detach linked users +or a preexisting/shared source. SQLite and PostgreSQL transaction semantics must +be tested against the real app packages; unsupported backends refuse. + +T05 and NK-WP-0048-T34 additionally require actual authenticated execution and +fresh inverse sessions, admitted evidence/recovery and exclusion of every release +writer, including Helm/runtime writers. The candidate DB primitive alone does +not establish those facilities. Helm rollback/omission does not remove persisted +OAuth sources: verify the exact app-state inverse before any image downgrade. +If links prevent removal, retain the extended image and reconcile. The earlier +T02 single-Helm-rollback statement is not sufficient recovery for this extension; +its historical text is retained and corrected here. + +## T09 — Enforce authenticated Access Engine decisions in the application path + +```task +id: RAPPS-WP-0016-T09 +status: todo +priority: high +state_hub_task_id: "d3804638-c73d-5b9f-a456-4262f62f9e6b" +``` + +Use the producer-owned decision format/canonical bytes and independently admitted +signer/policy/request pins. Forgejo must verify the caller's issuer/tenant and +assurance facts, obtain and authenticate the exact Access Engine resource/action +decision, and refuse missing, stale, wrong-pin, unavailable or malformed replies. +Keep the original three-group any-of access set as policy input; its local +compatibility gate is not a substitute PDP or an architecture exception. An +issuer allowed/denied claim cannot manufacture authorization. Source integration +needs real provider/callback/session wiring and meaningful producer-consumer +positive/adverse tests before re-freezing the case and package. Existing break- +glass/local account and linking behavior remain within the declared owner scope.