diff --git a/docs/extensions/README.md b/docs/extensions/README.md index 104e8eb..e93ebe9 100644 --- a/docs/extensions/README.md +++ b/docs/extensions/README.md @@ -408,6 +408,16 @@ Secrets are split across two `spec.yaml` sections: describe how the gateway injects that credential as an HTTP header when it proxies requests to those hosts. The service-id in `serviceAuth` and `serviceDomains` is the same key used under `credentials.sources`. +- `network.serviceAuth..hostsFromCredential` names another + `credentials.sources` id whose resolved value is a host, for services that + can point at a self-hosted instance. When that credential resolves, its value + **replaces** the declared `hosts` for this service — it is not additive, + since an instance-specific token must not be released to the public default + host as well. The replacement host joins the allow set like a declared one, + so one env var is enough to make the tool reach the instance with token + injection working. An unset credential keeps the declared hosts; a value that + is not a usable host warns and is ignored. Values may be a bare host, + `host:port`, or a full URL — the scheme, port and path are stripped. The placeholder convention is Go `fmt`-style `%s`, not `{secret}`. An empty `valueFormat` means "inject the raw secret value" with no wrapping (see diff --git a/extensions/features/gitlab-cli/README.md b/extensions/features/gitlab-cli/README.md index 0bbc4c5..7bc1f14 100644 --- a/extensions/features/gitlab-cli/README.md +++ b/extensions/features/gitlab-cli/README.md @@ -8,3 +8,28 @@ requests from the command line. Opt-in (disabled by default). ## Installation Downloads the latest `.deb` release from GitLab's release API. Requires root. + +## Auth + +`GITLAB_TOKEN`/`GITLAB_ACCESS_TOKEN`, `OAUTH_TOKEN` and +`JOB_TOKEN`/`CI_JOB_TOKEN` are resolved from the host environment, the layered +secrets files, or the persisted env store, and injected as env vars. `spec.yaml` +maps each to the header the gateway injects. + +## Self-hosted instances + +Set `GITLAB_HOST` to point `glab` at a self-hosted instance: + +```bash +GITLAB_HOST=gitlab.example.com enclave --features +gitlab-cli +``` + +That single env var selects the instance for `glab`, retargets token injection, +and joins the network allowlist — no spec edit or `allow_domains` entry. See +`serviceAuth.hostsFromCredential` in `docs/extensions/README.md` for the +accepted value forms and why the host replaces the `gitlab.com` defaults instead +of adding to them. + +To set it persistently, put it in a secrets file rather than the shell — global +in `~/.local/state/enclave/secrets/global.env`, or per project under +`~/.local/state/enclave/projects//`. diff --git a/extensions/features/gitlab-cli/spec.yaml b/extensions/features/gitlab-cli/spec.yaml index a0ae719..8a7077a 100644 --- a/extensions/features/gitlab-cli/spec.yaml +++ b/extensions/features/gitlab-cli/spec.yaml @@ -18,8 +18,11 @@ credentials: gitlab-token: { env: [GITLAB_TOKEN, GITLAB_ACCESS_TOKEN] } gitlab-oauth-token: { env: [OAUTH_TOKEN] } gitlab-job-token: { env: [JOB_TOKEN, CI_JOB_TOKEN] } + # Not a credential: glab reads GITLAB_HOST to pick the instance, and + # hostsFromCredential below retargets token injection at it. + gitlab-host: { env: [GITLAB_HOST], apiKey: false } network: serviceAuth: - gitlab-token: { headerName: private-token, hosts: [gitlab.com, "*.gitlab.com"] } - gitlab-oauth-token: { headerName: authorization, valueFormat: "Bearer %s", hosts: [gitlab.com, "*.gitlab.com"] } - gitlab-job-token: { headerName: job-token, hosts: [gitlab.com, "*.gitlab.com"] } + gitlab-token: { headerName: private-token, hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host } + gitlab-oauth-token: { headerName: authorization, valueFormat: "Bearer %s", hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host } + gitlab-job-token: { headerName: job-token, hosts: [gitlab.com, "*.gitlab.com"], hostsFromCredential: gitlab-host } diff --git a/internal/config/secrets.go b/internal/config/secrets.go index 3e833c6..31a33e1 100644 --- a/internal/config/secrets.go +++ b/internal/config/secrets.go @@ -241,9 +241,10 @@ func normalizeSecretRelease(secretID string, release *model.SecretReleaseConfig) return &model.SecretReleaseConfig{ HTTP: &model.HTTPSecretReleaseConfig{ - Hosts: hosts, - Header: header, - Format: format, + Hosts: hosts, + Header: header, + Format: format, + HostsFromSecret: strings.TrimSpace(release.HTTP.HostsFromSecret), }, }, nil } diff --git a/internal/config/spec.go b/internal/config/spec.go index f045702..7390a0d 100644 --- a/internal/config/spec.go +++ b/internal/config/spec.go @@ -129,6 +129,11 @@ type specServiceAuth struct { // these hosts are unioned with any serviceDomains entries pointing at this // service id. Hosts []string `json:"hosts,omitempty"` + // HostsFromCredential names a credentials.sources id whose resolved value + // is a host. When that credential resolves at runtime, its value replaces + // Hosts for this service, so a self-hosted instance can be selected by + // setting one env var instead of editing the spec. + HostsFromCredential string `json:"hostsFromCredential,omitempty"` } type specEnvironment struct { diff --git a/internal/config/spec_map.go b/internal/config/spec_map.go index d66b63d..908bd15 100644 --- a/internal/config/spec_map.go +++ b/internal/config/spec_map.go @@ -31,10 +31,18 @@ func validateServiceAuthMappings(doc specDocument, specPath string) error { sources[id] = struct{}{} } } - for id := range doc.Network.ServiceAuth { + for id, auth := range doc.Network.ServiceAuth { if _, ok := sources[id]; !ok { return fmt.Errorf("%s: network.serviceAuth[%q] has no matching credentials.sources entry", specPath, id) } + if auth.HostsFromCredential == "" { + continue + } + // Same typo class as the service ids above: an unmatched reference + // would silently leave the service pinned to its static hosts. + if _, ok := sources[auth.HostsFromCredential]; !ok { + return fmt.Errorf("%s: network.serviceAuth[%q].hostsFromCredential references credential %q with no matching credentials.sources entry", specPath, id, auth.HostsFromCredential) + } } for host, id := range doc.Network.ServiceDomains { if _, ok := sources[id]; !ok { @@ -139,9 +147,10 @@ func buildSecrets(doc specDocument) map[string]model.SecretConfig { if auth, ok := doc.Network.ServiceAuth[id]; ok { sc.Release = &model.SecretReleaseConfig{ HTTP: &model.HTTPSecretReleaseConfig{ - Hosts: hostsByService[id], - Header: auth.HeaderName, - Format: auth.ValueFormat, + Hosts: hostsByService[id], + Header: auth.HeaderName, + Format: auth.ValueFormat, + HostsFromSecret: auth.HostsFromCredential, }, } } diff --git a/internal/config/spec_map_test.go b/internal/config/spec_map_test.go index 0e1a102..9f27727 100644 --- a/internal/config/spec_map_test.go +++ b/internal/config/spec_map_test.go @@ -57,6 +57,46 @@ network: } }) + t.Run("unmatched hostsFromCredential fails", func(t *testing.T) { + doc := mustDoc(t, ` +schemaVersion: "1" +kind: mixin +name: gitlab-cli +credentials: + sources: + gitlab-token: { env: [GITLAB_TOKEN] } + gitlab-host: { env: [GITLAB_HOST], apiKey: false } +network: + serviceAuth: + gitlab-token: { headerName: private-token, hostsFromCredential: gitlab-hsot } +`) + if err := validateServiceAuthMappings(doc, "gitlab-cli/spec.yaml"); err == nil { + t.Fatal("expected error for hostsFromCredential with no matching credentials.sources entry") + } + }) + + t.Run("matching hostsFromCredential passes", func(t *testing.T) { + doc := mustDoc(t, ` +schemaVersion: "1" +kind: mixin +name: gitlab-cli +credentials: + sources: + gitlab-token: { env: [GITLAB_TOKEN] } + gitlab-host: { env: [GITLAB_HOST], apiKey: false } +network: + serviceAuth: + gitlab-token: { headerName: private-token, hosts: [gitlab.com], hostsFromCredential: gitlab-host } +`) + if err := validateServiceAuthMappings(doc, "gitlab-cli/spec.yaml"); err != nil { + t.Fatalf("validateServiceAuthMappings() error = %v, want nil", err) + } + secrets := buildSecrets(doc) + if got := secrets["gitlab-token"].Release.HTTP.HostsFromSecret; got != "gitlab-host" { + t.Fatalf("HostsFromSecret = %q, want %q", got, "gitlab-host") + } + }) + t.Run("matching ids pass", func(t *testing.T) { doc := mustDoc(t, ` schemaVersion: "1" diff --git a/internal/config/testdata/golden/feature-gitlab-cli.json b/internal/config/testdata/golden/feature-gitlab-cli.json index ef77bfb..c94ada3 100644 --- a/internal/config/testdata/golden/feature-gitlab-cli.json +++ b/internal/config/testdata/golden/feature-gitlab-cli.json @@ -5,6 +5,12 @@ "needs_root": true, "priority": 50, "secrets": { + "gitlab-host": { + "env_vars": [ + "GITLAB_HOST" + ], + "api_key": false + }, "gitlab-job-token": { "env_vars": [ "JOB_TOKEN", @@ -16,7 +22,8 @@ "*.gitlab.com", "gitlab.com" ], - "header": "job-token" + "header": "job-token", + "hosts_from_secret": "gitlab-host" } } }, @@ -31,7 +38,8 @@ "gitlab.com" ], "header": "authorization", - "format": "Bearer %s" + "format": "Bearer %s", + "hosts_from_secret": "gitlab-host" } } }, @@ -46,7 +54,8 @@ "*.gitlab.com", "gitlab.com" ], - "header": "private-token" + "header": "private-token", + "hosts_from_secret": "gitlab-host" } } } diff --git a/internal/model/types.go b/internal/model/types.go index b382899..9bb1726 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -260,6 +260,10 @@ type HTTPSecretReleaseConfig struct { Hosts []string `json:"hosts"` Header string `json:"header"` Format string `json:"format,omitempty"` + // HostsFromSecret names another secret id whose resolved value is a host. + // When that secret resolves, its value replaces Hosts for this release + // rule; see spec.yaml network.serviceAuth.hostsFromCredential. + HostsFromSecret string `json:"hosts_from_secret,omitempty"` } // ReleaseHosts returns the sorted, deduplicated HTTP release hosts declared diff --git a/internal/runtime/active_secrets.go b/internal/runtime/active_secrets.go index 9379a30..102420d 100644 --- a/internal/runtime/active_secrets.go +++ b/internal/runtime/active_secrets.go @@ -11,7 +11,10 @@ import ( "fmt" "os" "sort" + "strings" + "enclave/internal/domainpattern" + "enclave/internal/logx" "enclave/internal/model" "enclave/internal/secretfile" ) @@ -163,6 +166,79 @@ func resolveEnvAliasValue(secret activeSecret, layeredSecrets map[string]string, return chosen.value, chosen.source, true, nil } +// resolveReleaseHostOverrides resolves the credentials named by each release +// rule's HostsFromSecret and returns secret-id -> replacement hosts. A service +// whose host credential is unset or unusable keeps its declared hosts, so the +// default (e.g. gitlab.com) still applies. +// +// The replacement is deliberately not additive: the referenced credential names +// the one instance the token belongs to, and injecting an instance-specific +// token into the default hosts as well would expose it to a service that cannot +// accept it. +func resolveReleaseHostOverrides(secrets []activeSecret, hostHome string, layeredSecrets map[string]string, persistedEnv map[string]string) map[string][]string { + // Several services commonly share one host credential (gitlab's three + // tokens), so group by the reference and resolve each credential once. + byID := make(map[string]activeSecret, len(secrets)) + referencedBy := map[string][]string{} + for _, secret := range secrets { + byID[secret.ID] = secret + if secret.ReleaseHTTP != nil && secret.ReleaseHTTP.HostsFromSecret != "" { + ref := secret.ReleaseHTTP.HostsFromSecret + referencedBy[ref] = append(referencedBy[ref], secret.ID) + } + } + + overrides := map[string][]string{} + for ref, secretIDs := range referencedBy { + source, ok := byID[ref] + if !ok { + continue + } + value, _, found, err := resolveActiveSecretValue(source, hostHome, layeredSecrets, persistedEnv) + if err != nil { + logx.Warnf("Cannot resolve host credential %s (%v); keeping declared hosts.", ref, err) + continue + } + if !found { + continue + } + host := normalizeReleaseHost(ref, value) + if host == "" { + continue + } + for _, id := range secretIDs { + overrides[id] = []string{host} + } + } + return overrides +} + +// normalizeReleaseHost turns a credential value into an allowlist-comparable +// host, tolerating the URL forms CLIs accept (glab reads GITLAB_HOST as either +// a bare host or a full URL). An unusable value warns and is dropped rather +// than failing the session, since the declared hosts remain valid. +func normalizeReleaseHost(secretID string, value string) string { + trimmed := value + if index := strings.Index(trimmed, "://"); index >= 0 { + trimmed = trimmed[index+len("://"):] + } + if index := strings.IndexAny(trimmed, "/?#"); index >= 0 { + trimmed = trimmed[:index] + } + // NormalizeHost strips the port and IPv6 brackets but does not validate the + // labels, so the pattern validator runs after it to reject junk that would + // otherwise become a bogus allowlist entry. + host, err := domainpattern.NormalizeHost(trimmed) + if err == nil { + host, err = domainpattern.Normalize(host) + } + if err != nil { + logx.Warnf("Secret %s: value %q is not a usable host (%v); keeping declared hosts.", secretID, value, err) + return "" + } + return host +} + // resolveFileSecretValue reads the secret's file source, if any. A missing file // (or an empty resolved value) reports found=false so the caller can fall back // to the env aliases; a malformed parser or file content fails loudly. diff --git a/internal/runtime/auth_manager.go b/internal/runtime/auth_manager.go index 2670254..8aaca07 100644 --- a/internal/runtime/auth_manager.go +++ b/internal/runtime/auth_manager.go @@ -168,6 +168,9 @@ func (m authManager) injectDeclaredSecrets(hooks auth.Hooks, authCtx auth.Contex for _, secret := range suppressedActiveSecrets { logx.Debugf("Suppressed declared API key secret %s due to %s", secret.ID, suppressionReason) } + // Must precede shouldUseSecretReleases: that call resolves and caches the + // effective policy, which unions the release hosts into the allow set. + m.releaseHostOverrides = resolveReleaseHostOverrides(eligibleSecrets, m.host.Home, layeredSecrets, persistedEnv) secretReleaseEnabled := m.shouldUseSecretReleases(eligibleSecrets) for _, secret := range eligibleSecrets { secretValue, secretSource, found, err := resolveActiveSecretValue(secret, m.host.Home, layeredSecrets, persistedEnv) @@ -190,7 +193,7 @@ func (m authManager) injectDeclaredSecrets(hooks auth.Hooks, authCtx auth.Contex SecretID: secret.ID, Placeholder: placeholder, Value: secretValue, - Hosts: append([]string{}, secret.ReleaseHTTP.Hosts...), + Hosts: m.releaseHostsFor(secret), Header: secret.ReleaseHTTP.Header, Format: secret.ReleaseHTTP.Format, }) @@ -414,6 +417,15 @@ func (m authManager) apiKeySecretSuppressionReason() string { return "" } +// releaseHostsFor returns the hosts a secret's release rule applies to, after +// any serviceAuth.hostsFromCredential replacement. +func (m authManager) releaseHostsFor(secret activeSecret) []string { + if hosts, ok := m.releaseHostOverrides[secret.ID]; ok { + return append([]string{}, hosts...) + } + return append([]string{}, secret.ReleaseHTTP.Hosts...) +} + func (m authManager) shouldUseSecretReleases(activeSecrets []activeSecret) bool { hasHTTPRelease := false for _, secret := range activeSecrets { diff --git a/internal/runtime/auth_manager_test.go b/internal/runtime/auth_manager_test.go index 44179ab..4d950ee 100644 --- a/internal/runtime/auth_manager_test.go +++ b/internal/runtime/auth_manager_test.go @@ -824,9 +824,10 @@ func secretConfig(envVars []string, release *model.HTTPSecretReleaseConfig) mode if release != nil { cfg.Release = &model.SecretReleaseConfig{ HTTP: &model.HTTPSecretReleaseConfig{ - Hosts: append([]string{}, release.Hosts...), - Header: release.Header, - Format: release.Format, + Hosts: append([]string{}, release.Hosts...), + Header: release.Header, + Format: release.Format, + HostsFromSecret: release.HostsFromSecret, }, } } diff --git a/internal/runtime/release_hosts_test.go b/internal/runtime/release_hosts_test.go new file mode 100644 index 0000000..c9fbfad --- /dev/null +++ b/internal/runtime/release_hosts_test.go @@ -0,0 +1,109 @@ +// Copyright (C) 2026 EclipseSource GmbH and others. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// +// SPDX-License-Identifier: MIT + +package runtime + +import ( + "strings" + "testing" + + "enclave/internal/model" +) + +// gitlabLikeProfile mirrors the gitlab-cli shape: several services sharing one +// host credential via serviceAuth.hostsFromCredential. +func gitlabLikeProfile() model.Profile { + return model.Profile{ + Name: "tool", + Secrets: map[string]model.SecretConfig{ + "gitlab-host": secretConfig([]string{"GITLAB_HOST"}, nil), + "gitlab-token": secretConfig([]string{"GITLAB_TOKEN"}, &model.HTTPSecretReleaseConfig{ + Hosts: []string{"gitlab.com", "*.gitlab.com"}, + Header: "private-token", + HostsFromSecret: "gitlab-host", + }), + "gitlab-job-token": secretConfig([]string{"JOB_TOKEN"}, &model.HTTPSecretReleaseConfig{ + Hosts: []string{"gitlab.com", "*.gitlab.com"}, + Header: "job-token", + HostsFromSecret: "gitlab-host", + }), + }, + } +} + +func TestResolveReleaseHostOverridesReplacesDeclaredHosts(t *testing.T) { + t.Setenv("GITLAB_HOST", "gitlab.example.com") + + r := runtimeWithProfile(t, gitlabLikeProfile()) + overrides := resolveReleaseHostOverrides(mustActiveSecrets(t, r), r.host.Home, map[string]string{}, map[string]string{}) + + for _, id := range []string{"gitlab-token", "gitlab-job-token"} { + hosts, ok := overrides[id] + if !ok { + t.Fatalf("overrides[%s] missing, want the resolved host", id) + } + // Replacement, not addition: the instance token must not be released + // to gitlab.com as well. + if len(hosts) != 1 || hosts[0] != "gitlab.example.com" { + t.Fatalf("overrides[%s] = %v, want [gitlab.example.com]", id, hosts) + } + } + if _, ok := overrides["gitlab-host"]; ok { + t.Fatalf("overrides contains the host credential itself, want only referencing services") + } +} + +func TestNormalizeReleaseHost(t *testing.T) { + cases := map[string]string{ + "https://gitlab.example.com/group": "gitlab.example.com", + "gitlab.example.com:8443": "gitlab.example.com", + " GitLab.Example.com ": "gitlab.example.com", + "not a host": "", + } + for value, want := range cases { + t.Run(value, func(t *testing.T) { + if got := normalizeReleaseHost("gitlab-host", value); got != want { + t.Fatalf("normalizeReleaseHost(%q) = %q, want %q", value, got, want) + } + }) + } +} + +func TestResolveReleaseHostOverridesKeepsDeclaredHostsWhenUnset(t *testing.T) { + t.Setenv("GITLAB_HOST", "") + + r := runtimeWithProfile(t, gitlabLikeProfile()) + overrides := resolveReleaseHostOverrides(mustActiveSecrets(t, r), r.host.Home, map[string]string{}, map[string]string{}) + + if len(overrides) != 0 { + t.Fatalf("overrides = %v, want none when the host credential is unset", overrides) + } +} + +// The replacement host must reach the allow set, or the release rule would name +// a host the sandbox cannot resolve, and it must not leak into the loaded spec, +// which is shared across the session. +func TestSpecNetworkDomainsIncludesOverriddenReleaseHost(t *testing.T) { + r := runtimeWithProfile(t, gitlabLikeProfile()) + r.releaseHostOverrides = map[string][]string{ + "gitlab-token": {"gitlab.example.com"}, + "gitlab-job-token": {"gitlab.example.com"}, + } + + allowed, _ := r.specNetworkDomains() + joined := strings.Join(allowed, ",") + + if !strings.Contains(joined, "gitlab.example.com") { + t.Fatalf("allowed = %v, want the overridden host included", allowed) + } + if strings.Contains(joined, "gitlab.com,") || strings.Contains(joined, ",gitlab.com") { + t.Fatalf("allowed = %v, want the replaced default host dropped", allowed) + } + if hosts := r.profile.Secrets["gitlab-token"].Release.HTTP.Hosts; strings.Join(hosts, ",") != "gitlab.com,*.gitlab.com" { + t.Fatalf("profile hosts = %v, want the declared hosts unchanged", hosts) + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index d385efb..a537c8d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -10,6 +10,7 @@ package runtime import ( "context" "fmt" + "maps" "os" "path/filepath" "regexp" @@ -59,7 +60,12 @@ type Runtime struct { policyResolved bool policyResult policy.ResolveResult policyErr error - backend backend.Backend + // releaseHostOverrides maps a secret id to the hosts that replace its + // declared release hosts, resolved from serviceAuth.hostsFromCredential. + // Populated during auth setup, before the effective policy is resolved and + // cached, so the replacement host reaches the allow set too. + releaseHostOverrides map[string][]string + backend backend.Backend } type ExecutionContext struct { @@ -982,15 +988,39 @@ func (r *Runtime) specProxyManaged() []string { func (r *Runtime) specNetworkDomains() (allowed []string, denied []string) { allowed = append([]string(nil), r.profile.AllowedDomains...) denied = append([]string(nil), r.profile.DeniedDomains...) - allowed = append(allowed, model.ReleaseHosts(r.profile.Secrets)...) + allowed = append(allowed, r.releaseHosts(r.profile.Secrets)...) for _, feature := range r.features { allowed = append(allowed, feature.AllowedDomains...) denied = append(denied, feature.DeniedDomains...) - allowed = append(allowed, model.ReleaseHosts(feature.Secrets)...) + allowed = append(allowed, r.releaseHosts(feature.Secrets)...) } return allowed, denied } +// releaseHosts is model.ReleaseHosts with any resolved +// serviceAuth.hostsFromCredential replacement applied, so a host selected at +// runtime becomes resolvable exactly like a declared one. The loaded spec is +// left untouched. +func (r *Runtime) releaseHosts(secrets map[string]model.SecretConfig) []string { + if len(r.releaseHostOverrides) == 0 { + return model.ReleaseHosts(secrets) + } + effective := maps.Clone(secrets) + for id, hosts := range r.releaseHostOverrides { + sc, ok := effective[id] + if !ok || sc.Release == nil || sc.Release.HTTP == nil { + continue + } + release := *sc.Release + http := *release.HTTP + http.Hosts = hosts + release.HTTP = &http + sc.Release = &release + effective[id] = sc + } + return model.ReleaseHosts(effective) +} + // addWorktreeMetadataMounts mounts the linked-worktree gitdir/commondir // according to worktree_metadata: follow ties them to the project mount mode, // readonly forces them read-only, none skips them entirely.