access-engine: secret-read evaluator for warden fetch checks (CUST-WP-0075)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 3775@bnt-lap001 Assistant-Session: cfacb6a5-ad5b-4f79-ac67-053e3b0d34a3
This commit is contained in:
parent
1309c093bb
commit
02b15a7f72
16 changed files with 1542 additions and 0 deletions
|
|
@ -39,6 +39,10 @@ spec:
|
|||
- {{ required "callerAuth.kubernetesURL is required when callerAuth.mode is warn or enforce" .Values.callerAuth.kubernetesURL | quote }}
|
||||
- --caller-binding
|
||||
- {{ $binding | quote }}
|
||||
{{- range .Values.callerAuth.extraBindings }}
|
||||
- --caller-binding
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ callerAuth:
|
|||
audience: flex-auth
|
||||
kubernetesURL: https://kubernetes.default.svc
|
||||
binding: ""
|
||||
# Further resource.system=principal pairs, each rendered as its own
|
||||
# --caller-binding (e.g. ops-warden-fetch binds every catalog owner system
|
||||
# to the ops-warden ServiceAccount).
|
||||
extraBindings: []
|
||||
reviewer:
|
||||
mountPath: /var/run/secrets/flex-auth-reviewer
|
||||
# Empty: kubelet mints the API server's default audiences so TokenReview
|
||||
|
|
|
|||
219
cmd/flex-auth/ops_warden_fetch_test.go
Normal file
219
cmd/flex-auth/ops_warden_fetch_test.go
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/callerauth"
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
// The ops-warden secret-read pin (CUST-WP-0075, bug b792c5d0): warden's
|
||||
// check_fetch_policy sends action read on resource type secret with
|
||||
// resource.system = the lane's owner_repo.
|
||||
|
||||
const opsWardenPrincipal = "system:serviceaccount:ops-warden:ops-warden"
|
||||
|
||||
func fetchPath(name string) string {
|
||||
return filepath.Join("..", "..", "examples", "ops-warden-fetch", name)
|
||||
}
|
||||
|
||||
func loadFetchRequest(t *testing.T, name string) api.CheckRequest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(fetchPath(name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var request api.CheckRequest
|
||||
if err := json.Unmarshal(raw, &request); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func TestOpsWardenFetchDecisions(t *testing.T) {
|
||||
engine, err := buildEngine(context.Background(), fetchPath("registry_snapshot.json"), fetchPath("policy_package.md"), "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildEngine: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
file string
|
||||
effect api.DecisionEffect
|
||||
reason string
|
||||
}{
|
||||
{"check_request_allow_forgejo_admin_api_token.json", api.DecisionEffectAllow, "secret_read_lane_matched"},
|
||||
{"check_request_allow_openrouter_llm_connect.json", api.DecisionEffectAllow, "secret_read_lane_matched"},
|
||||
{"check_request_deny_unknown_need.json", api.DecisionEffectDeny, "unknown_need"},
|
||||
{"check_request_deny_wrong_system.json", api.DecisionEffectDeny, "wrong_system"},
|
||||
{"check_request_deny_non_secret_type.json", api.DecisionEffectDeny, "wrong_resource_type"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
decision, err := engine.Check(context.Background(), loadFetchRequest(t, tc.file))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decision.Effect != tc.effect || decision.Reason != tc.reason {
|
||||
t.Fatalf("decision = %s/%s; want %s/%s", decision.Effect, decision.Reason, tc.effect, tc.reason)
|
||||
}
|
||||
if decision.ID == "" {
|
||||
t.Fatal("decision.ID is empty; warden needs a policy_decision_id")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fetchBindings reads the caller bindings exactly as the values file declares
|
||||
// them, so the test exercises the configuration that would be deployed.
|
||||
func fetchBindings(t *testing.T) (string, map[string]string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "values", "ops-warden-fetch.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var values struct {
|
||||
CallerAuth struct {
|
||||
Mode string `yaml:"mode"`
|
||||
Binding string `yaml:"binding"`
|
||||
ExtraBindings []string `yaml:"extraBindings"`
|
||||
} `yaml:"callerAuth"`
|
||||
}
|
||||
if err := yaml.Unmarshal(raw, &values); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bindings := map[string]string{}
|
||||
for _, pair := range append([]string{values.CallerAuth.Binding}, values.CallerAuth.ExtraBindings...) {
|
||||
system, principal, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
t.Fatalf("malformed binding %q", pair)
|
||||
}
|
||||
bindings[system] = principal
|
||||
}
|
||||
return values.CallerAuth.Mode, bindings
|
||||
}
|
||||
|
||||
func TestOpsWardenFetchCallerBindings(t *testing.T) {
|
||||
mode, bindings := fetchBindings(t)
|
||||
if mode != "enforce" {
|
||||
t.Fatalf("callerAuth.mode = %q; the fetch pin must enforce", mode)
|
||||
}
|
||||
for system, principal := range bindings {
|
||||
if principal != opsWardenPrincipal {
|
||||
t.Fatalf("system %q bound to %q; only ops-warden may ask", system, principal)
|
||||
}
|
||||
}
|
||||
engine, err := buildEngine(context.Background(), fetchPath("registry_snapshot.json"), fetchPath("policy_package.md"), "")
|
||||
if err != nil {
|
||||
t.Fatalf("buildEngine: %v", err)
|
||||
}
|
||||
serve := func(username string) *httptest.Server {
|
||||
authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{
|
||||
Username: username,
|
||||
Audiences: []string{"flex-auth"},
|
||||
}}, "flex-auth", bindings, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return httptest.NewServer(newServeMuxWithCallerAuth(engine, authenticator))
|
||||
}
|
||||
post := func(server *httptest.Server, request api.CheckRequest, token bool) (int, api.DecisionEnvelope) {
|
||||
body, _ := json.Marshal(request)
|
||||
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body))
|
||||
req.Header.Set("content-type", "application/json")
|
||||
if token {
|
||||
req.Header.Set("authorization", "Bearer workload-token")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var envelope api.DecisionEnvelope
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, envelope
|
||||
}
|
||||
|
||||
warden := serve(opsWardenPrincipal)
|
||||
defer warden.Close()
|
||||
allow := loadFetchRequest(t, "check_request_allow_forgejo_admin_api_token.json")
|
||||
|
||||
if code, envelope := post(warden, allow, true); code != http.StatusOK || envelope.Effect != api.DecisionEffectAllow {
|
||||
t.Fatalf("ops-warden forgejo read = %d %s/%s; want 200 allow", code, envelope.Effect, envelope.Reason)
|
||||
}
|
||||
if code, _ := post(warden, allow, false); code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous = %d; want 401", code)
|
||||
}
|
||||
unbound := allow
|
||||
unbound.Resource.System = "secrets-engine"
|
||||
unbound.Context = map[string]any{"need_id": "forgejo-admin-api-token", "owner_repo": "secrets-engine"}
|
||||
if code, _ := post(warden, unbound, true); code != http.StatusForbidden {
|
||||
t.Fatalf("ops-warden on unbound system = %d; want 403", code)
|
||||
}
|
||||
|
||||
other := serve("system:serviceaccount:another:caller")
|
||||
defer other.Close()
|
||||
if code, _ := post(other, allow, true); code != http.StatusForbidden {
|
||||
t.Fatalf("wrong caller = %d; want 403", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpsWardenFetchRegistryMatchesPolicy keeps the two generated artifacts in
|
||||
// step: every catalog lane in the package is a registry resource of its owner
|
||||
// system, and nothing else is. Drift against the catalog itself is
|
||||
// tools/gen_ops_warden_fetch.py --check (tests/stage1.sh).
|
||||
func TestOpsWardenFetchRegistryMatchesPolicy(t *testing.T) {
|
||||
raw, err := os.ReadFile(fetchPath("policy_package.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block := regexp.MustCompile(`(?s)catalog_lanes := \{(.*?)\}`).FindSubmatch(raw)
|
||||
if block == nil {
|
||||
t.Fatal("catalog_lanes block not found")
|
||||
}
|
||||
var fromPolicy []string
|
||||
for _, m := range regexp.MustCompile(`"([^"]+)": "([^"]+)"`).FindAllSubmatch(block[1], -1) {
|
||||
fromPolicy = append(fromPolicy, string(m[2])+"/secret:"+string(m[1]))
|
||||
}
|
||||
|
||||
snapRaw, err := os.ReadFile(fetchPath("registry_snapshot.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snap struct {
|
||||
ResourceManifests []struct {
|
||||
System string `json:"system"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"resources"`
|
||||
} `json:"resource_manifests"`
|
||||
}
|
||||
if err := json.Unmarshal(snapRaw, &snap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fromRegistry []string
|
||||
for _, manifest := range snap.ResourceManifests {
|
||||
for _, resource := range manifest.Resources {
|
||||
fromRegistry = append(fromRegistry, manifest.System+"/"+resource.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(fromPolicy)
|
||||
sort.Strings(fromRegistry)
|
||||
if len(fromPolicy) == 0 || strings.Join(fromPolicy, ",") != strings.Join(fromRegistry, ",") {
|
||||
t.Fatalf("policy lanes %v\nregistry %v", fromPolicy, fromRegistry)
|
||||
}
|
||||
}
|
||||
84
examples/ops-warden-fetch/README.md
Normal file
84
examples/ops-warden-fetch/README.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# ops-warden secret-read example (`flex-auth-ops-warden-fetch`)
|
||||
|
||||
Policy, registry and fixtures for ops-warden's **secret-fetch** policy check
|
||||
(`check_fetch_policy` in ops-warden `src/warden/policy.py`): before
|
||||
`warden access <need> --fetch/--exec` proxies a non-SSH credential read, it asks
|
||||
whether `read` on `secret:<need_id>[/<domain>]` owned by `<owner_repo>` is
|
||||
allowed. Opened by the-custodian bug `b792c5d0` (CUST-WP-0075): those checks
|
||||
were sent to the SSH signing pin `flex-auth-ops-warden`, whose package admits
|
||||
only `resource.system == "ops-warden"` and whose caller bindings know only that
|
||||
system, so every other owner got 403.
|
||||
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `policy_package.md` | `ops-warden.secret.read` v1, `allow_ttl: 5m`; the `catalog_lanes` block is generated |
|
||||
| `registry_snapshot.json` | generated: one system per owner, one `secret:<need_id>` resource per lane |
|
||||
| `policy_fixtures.yaml` | 15 fixtures: 4 allows, 11 denial branches |
|
||||
| `check_request_*.json` | standalone requests for `POST /v1/check` |
|
||||
| `../../values/ops-warden-fetch.yaml` | the pin; caller bindings generated, image digest set by hand after CI |
|
||||
|
||||
## Why a separate pin
|
||||
|
||||
One decision class per pin, independently rollable (FLEX-WP-0016). The server
|
||||
serves one policy package per process. SSH signing (`sign` on
|
||||
`ssh-certificate`, system `ops-warden`) and secret reads (`read` on `secret`,
|
||||
system = owner repo) differ in action, resource type, systems and caller
|
||||
bindings. Folding reads into the signing pin would redeploy the lane the
|
||||
ops-bridge tunnels depend on for every catalog change.
|
||||
|
||||
## The rule
|
||||
|
||||
Allow when all hold, otherwise deny with the first failing reason:
|
||||
`action == read` (`wrong_action`), `resource.type == secret`
|
||||
(`wrong_resource_type`), tenant `tenant:platform` (`wrong_tenant`),
|
||||
`subject.type == operator` (`wrong_subject_type`), non-empty subject id
|
||||
(`missing_subject`), `context.need_id` present (`missing_need_id`) and in the
|
||||
catalog set (`unknown_need`), `resource.system` a catalog owner
|
||||
(`unknown_system`) and the owner of that need (`wrong_system`),
|
||||
`resource.id == secret:<need_id>` or `secret:<need_id>/<context.domain>`
|
||||
(`resource_need_mismatch`), `context.owner_repo == resource.system`
|
||||
(`owner_repo_mismatch`).
|
||||
|
||||
Caller authentication is `enforce`: every owner system is bound to
|
||||
`system:serviceaccount:ops-warden:ops-warden`. Another caller, an anonymous
|
||||
call, or an owner system missing from the bindings is rejected with 401/403
|
||||
before the package runs.
|
||||
|
||||
For a registered resource the registry's `type` wins over the caller's
|
||||
(FLEX-DEC-2026-012), so a non-secret type only reaches the package on an
|
||||
unregistered id; the package denies it either way.
|
||||
|
||||
## Regenerate when the catalog changes
|
||||
|
||||
The readable set is every ops-warden catalog entry with `status: active`,
|
||||
`exec_capable: true` and `lane: secret`. After a lane is added or retired:
|
||||
|
||||
```bash
|
||||
python3 tools/gen_ops_warden_fetch.py # rewrites the three generated files
|
||||
python3 tools/gen_ops_warden_fetch.py --check # exit 1 on drift (tests/stage1.sh runs it)
|
||||
go run ./cmd/flex-auth test-policy --file examples/ops-warden-fetch/policy_package.md
|
||||
```
|
||||
|
||||
`--catalog PATH` overrides the default `~/ops-warden/registry/routing/catalog.yaml`.
|
||||
A new **owner system** adds a caller binding, so it needs a pin redeploy. A new
|
||||
lane under an existing owner changes only the package and registry, which are
|
||||
baked into the image, so it needs a new image too (`deploy/README.md`).
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go run ./cmd/flex-auth test-policy --file examples/ops-warden-fetch/policy_package.md # 6 Rego tests, 15 fixtures
|
||||
go run ./cmd/flex-auth load-registry -file examples/ops-warden-fetch/registry_snapshot.json
|
||||
go test ./cmd/flex-auth -run OpsWardenFetch
|
||||
```
|
||||
|
||||
## Not yet deployed
|
||||
|
||||
`values/ops-warden-fetch.yaml` carries no image digest until CI builds an image
|
||||
containing this directory. The deploy steps (founder go required) are in
|
||||
`workplans/ADHOC-2026-10-03.md`.
|
||||
|
||||
Residual (build mode, close-down checklist row 1): `subject.id` is whatever
|
||||
ops-warden sends (its subject env or `operator`); it is not bound to an attended
|
||||
human identity. The caller binding proves that ops-warden asked, not which
|
||||
operator did.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"id": "check:ops-warden-fetch-forgejo-admin-api-token",
|
||||
"subject": {
|
||||
"id": "operator",
|
||||
"type": "operator",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"action": "read",
|
||||
"resource": {
|
||||
"id": "secret:forgejo-admin-api-token",
|
||||
"type": "secret",
|
||||
"system": "railiance-platform",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"context": {
|
||||
"need_id": "forgejo-admin-api-token",
|
||||
"owner_repo": "railiance-platform",
|
||||
"domain": null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"id": "check:ops-warden-fetch-openrouter-llm-connect",
|
||||
"subject": {
|
||||
"id": "operator",
|
||||
"type": "operator",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"action": "read",
|
||||
"resource": {
|
||||
"id": "secret:openrouter-llm-connect",
|
||||
"type": "secret",
|
||||
"system": "railiance-platform",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"context": {
|
||||
"need_id": "openrouter-llm-connect",
|
||||
"owner_repo": "railiance-platform",
|
||||
"domain": null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"id": "check:ops-warden-fetch-forgejo-admin-api-token",
|
||||
"subject": {
|
||||
"id": "operator",
|
||||
"type": "operator",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"action": "read",
|
||||
"resource": {
|
||||
"id": "ssh-cert:actor/platform-steward",
|
||||
"type": "ssh-certificate",
|
||||
"system": "railiance-platform",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"context": {
|
||||
"need_id": "forgejo-admin-api-token",
|
||||
"owner_repo": "railiance-platform",
|
||||
"domain": null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"id": "check:ops-warden-fetch-no-such-need",
|
||||
"subject": {
|
||||
"id": "operator",
|
||||
"type": "operator",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"action": "read",
|
||||
"resource": {
|
||||
"id": "secret:no-such-need",
|
||||
"type": "secret",
|
||||
"system": "railiance-platform",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"context": {
|
||||
"need_id": "no-such-need",
|
||||
"owner_repo": "railiance-platform",
|
||||
"domain": null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"id": "check:ops-warden-fetch-forgejo-admin-api-token",
|
||||
"subject": {
|
||||
"id": "operator",
|
||||
"type": "operator",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"action": "read",
|
||||
"resource": {
|
||||
"id": "secret:forgejo-admin-api-token",
|
||||
"type": "secret",
|
||||
"system": "key-cape",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"context": {
|
||||
"need_id": "forgejo-admin-api-token",
|
||||
"owner_repo": "key-cape",
|
||||
"domain": null
|
||||
}
|
||||
}
|
||||
300
examples/ops-warden-fetch/policy_fixtures.yaml
Normal file
300
examples/ops-warden-fetch/policy_fixtures.yaml
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
- id: fixture:ops-warden-fetch-allow-forgejo-admin-api-token
|
||||
request:
|
||||
id: check:ops-warden-fetch-forgejo-admin-api-token
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:forgejo-admin-api-token
|
||||
type: secret
|
||||
system: railiance-platform
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: forgejo-admin-api-token
|
||||
owner_repo: railiance-platform
|
||||
domain: null
|
||||
expect:
|
||||
effect: allow
|
||||
reason: secret_read_lane_matched
|
||||
- id: fixture:ops-warden-fetch-allow-openrouter-llm-connect
|
||||
request:
|
||||
id: check:ops-warden-fetch-openrouter-llm-connect
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:openrouter-llm-connect
|
||||
type: secret
|
||||
system: railiance-platform
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: openrouter-llm-connect
|
||||
owner_repo: railiance-platform
|
||||
domain: null
|
||||
expect:
|
||||
effect: allow
|
||||
reason: secret_read_lane_matched
|
||||
- id: fixture:ops-warden-fetch-allow-rapp-qonto-keycape-client
|
||||
request:
|
||||
id: check:ops-warden-fetch-rapp-qonto-keycape-client
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:rapp-qonto-keycape-client
|
||||
type: secret
|
||||
system: key-cape
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: rapp-qonto-keycape-client
|
||||
owner_repo: key-cape
|
||||
domain: null
|
||||
expect:
|
||||
effect: allow
|
||||
reason: secret_read_lane_matched
|
||||
- id: fixture:ops-warden-fetch-allow-domain-suffix
|
||||
request:
|
||||
id: check:ops-warden-fetch-forgejo-admin-api-token
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:forgejo-admin-api-token/infotech
|
||||
type: secret
|
||||
system: railiance-platform
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: forgejo-admin-api-token
|
||||
owner_repo: railiance-platform
|
||||
domain: infotech
|
||||
expect:
|
||||
effect: allow
|
||||
reason: secret_read_lane_matched
|
||||
- id: fixture:ops-warden-fetch-deny-unknown-need
|
||||
request:
|
||||
id: check:ops-warden-fetch-no-such-need
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:no-such-need
|
||||
type: secret
|
||||
system: railiance-platform
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: no-such-need
|
||||
owner_repo: railiance-platform
|
||||
domain: null
|
||||
expect:
|
||||
effect: deny
|
||||
reason: unknown_need
|
||||
- id: fixture:ops-warden-fetch-deny-not-exec-capable-need
|
||||
request:
|
||||
id: check:ops-warden-fetch-net-kingdom-lldap-bind-credential
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:net-kingdom-lldap-bind-credential
|
||||
type: secret
|
||||
system: railiance-platform
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: net-kingdom-lldap-bind-credential
|
||||
owner_repo: railiance-platform
|
||||
domain: null
|
||||
expect:
|
||||
effect: deny
|
||||
reason: unknown_need
|
||||
- id: fixture:ops-warden-fetch-deny-login-lane-need
|
||||
request:
|
||||
id: check:ops-warden-fetch-openbao-platform-admin-login
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:openbao-platform-admin-login
|
||||
type: secret
|
||||
system: railiance-platform
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: openbao-platform-admin-login
|
||||
owner_repo: railiance-platform
|
||||
domain: null
|
||||
expect:
|
||||
effect: deny
|
||||
reason: unknown_need
|
||||
- id: fixture:ops-warden-fetch-deny-wrong-system
|
||||
request:
|
||||
id: check:ops-warden-fetch-forgejo-admin-api-token
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:forgejo-admin-api-token
|
||||
type: secret
|
||||
system: key-cape
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: forgejo-admin-api-token
|
||||
owner_repo: key-cape
|
||||
domain: null
|
||||
expect:
|
||||
effect: deny
|
||||
reason: wrong_system
|
||||
- id: fixture:ops-warden-fetch-deny-unknown-system
|
||||
request:
|
||||
id: check:ops-warden-fetch-forgejo-admin-api-token
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:forgejo-admin-api-token
|
||||
type: secret
|
||||
system: secrets-engine
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: forgejo-admin-api-token
|
||||
owner_repo: secrets-engine
|
||||
domain: null
|
||||
expect:
|
||||
effect: deny
|
||||
reason: unknown_system
|
||||
- id: fixture:ops-warden-fetch-deny-non-secret-type
|
||||
request:
|
||||
id: check:ops-warden-fetch-forgejo-admin-api-token
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:forgejo-admin-api-token
|
||||
type: ssh-certificate
|
||||
system: railiance-platform
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: forgejo-admin-api-token
|
||||
owner_repo: railiance-platform
|
||||
domain: null
|
||||
expect:
|
||||
effect: deny
|
||||
reason: wrong_resource_type
|
||||
- id: fixture:ops-warden-fetch-deny-wrong-action
|
||||
request:
|
||||
id: check:ops-warden-fetch-forgejo-admin-api-token
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: write
|
||||
resource:
|
||||
id: secret:forgejo-admin-api-token
|
||||
type: secret
|
||||
system: railiance-platform
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: forgejo-admin-api-token
|
||||
owner_repo: railiance-platform
|
||||
domain: null
|
||||
expect:
|
||||
effect: deny
|
||||
reason: wrong_action
|
||||
- id: fixture:ops-warden-fetch-deny-wrong-tenant
|
||||
request:
|
||||
id: check:ops-warden-fetch-forgejo-admin-api-token
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:acme
|
||||
action: read
|
||||
resource:
|
||||
id: secret:forgejo-admin-api-token
|
||||
type: secret
|
||||
system: railiance-platform
|
||||
tenant: tenant:acme
|
||||
context:
|
||||
need_id: forgejo-admin-api-token
|
||||
owner_repo: railiance-platform
|
||||
domain: null
|
||||
expect:
|
||||
effect: deny
|
||||
reason: wrong_tenant
|
||||
- id: fixture:ops-warden-fetch-deny-wrong-subject-type
|
||||
request:
|
||||
id: check:ops-warden-fetch-forgejo-admin-api-token
|
||||
subject:
|
||||
id: operator
|
||||
type: agt
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:forgejo-admin-api-token
|
||||
type: secret
|
||||
system: railiance-platform
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: forgejo-admin-api-token
|
||||
owner_repo: railiance-platform
|
||||
domain: null
|
||||
expect:
|
||||
effect: deny
|
||||
reason: wrong_subject_type
|
||||
- id: fixture:ops-warden-fetch-deny-resource-need-mismatch
|
||||
request:
|
||||
id: check:ops-warden-fetch-forgejo-admin-api-token
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:openrouter-llm-connect
|
||||
type: secret
|
||||
system: railiance-platform
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: forgejo-admin-api-token
|
||||
owner_repo: railiance-platform
|
||||
domain: null
|
||||
expect:
|
||||
effect: deny
|
||||
reason: resource_need_mismatch
|
||||
- id: fixture:ops-warden-fetch-deny-owner-repo-mismatch
|
||||
request:
|
||||
id: check:ops-warden-fetch-forgejo-admin-api-token
|
||||
subject:
|
||||
id: operator
|
||||
type: operator
|
||||
tenant: tenant:platform
|
||||
action: read
|
||||
resource:
|
||||
id: secret:forgejo-admin-api-token
|
||||
type: secret
|
||||
system: railiance-platform
|
||||
tenant: tenant:platform
|
||||
context:
|
||||
need_id: forgejo-admin-api-token
|
||||
owner_repo: key-cape
|
||||
domain: null
|
||||
expect:
|
||||
effect: deny
|
||||
reason: owner_repo_mismatch
|
||||
246
examples/ops-warden-fetch/policy_package.md
Normal file
246
examples/ops-warden-fetch/policy_package.md
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
---
|
||||
id: ops-warden.secret.read
|
||||
name: Ops-Warden secret read (catalog fetch lanes)
|
||||
namespace: ops-warden:secret-read
|
||||
version: v1
|
||||
status: ready
|
||||
package: flexauth.ops_warden.secret_read
|
||||
allow_ttl: 5m
|
||||
actions:
|
||||
- read
|
||||
owner: team:platform-security
|
||||
fixtures:
|
||||
- policy_fixtures.yaml
|
||||
caring:
|
||||
profile: caring-0.4.0-rc2
|
||||
enforce: false
|
||||
canonical_roles:
|
||||
- Operator
|
||||
organization_relations:
|
||||
- ServiceProvider
|
||||
scopes:
|
||||
- level: Platform
|
||||
id: platform:secret-read
|
||||
tenant: tenant:platform
|
||||
planes:
|
||||
- Secret
|
||||
- Audit
|
||||
capabilities:
|
||||
- View
|
||||
- Use
|
||||
exposure_modes:
|
||||
- SecretMaterial
|
||||
conditions:
|
||||
- PurposeBound
|
||||
- TimeLimited
|
||||
- Logged
|
||||
restrictions:
|
||||
- PrivilegeEscalationBlocked
|
||||
activation:
|
||||
mode: local
|
||||
metadata:
|
||||
source: examples/ops-warden-fetch/policy_package.md
|
||||
generator: tools/gen_ops_warden_fetch.py
|
||||
catalog: ops-warden/registry/routing/catalog.yaml
|
||||
origin: CUST-WP-0075 (bug b792c5d0)
|
||||
---
|
||||
|
||||
# Ops-Warden Secret Read
|
||||
|
||||
ops-warden's `warden access <need> --fetch/--exec` asks this package, before it
|
||||
proxies a non-SSH credential read, whether `read` on `secret:<need_id>` owned by
|
||||
`<owner_repo>` is allowed (ops-warden `src/warden/policy.py`
|
||||
`check_fetch_policy`). ops-warden is the conduit, never the owner; the owner
|
||||
system is the lane's `owner_repo`, and no secret value is in the request.
|
||||
|
||||
The readable set is **not authored here**. `catalog_lanes` maps each need id to
|
||||
its owner system and is generated from ops-warden's routing catalog: every entry
|
||||
with `status: active`, `exec_capable: true` and `lane: secret`. Regenerate with
|
||||
`python3 tools/gen_ops_warden_fetch.py`; `--check` reports drift.
|
||||
|
||||
The set lives in the package rather than being read from registry attributes so
|
||||
that a caller cannot assert its way onto a lane the registry does not know (the
|
||||
engine passes caller attributes through for unregistered resources,
|
||||
FLEX-DEC-2026-012).
|
||||
|
||||
Who may ask is enforced before this package runs: the pin's caller bindings map
|
||||
every owner system to the ops-warden ServiceAccount, in `enforce` mode, so any
|
||||
other caller gets HTTP 403 and an owner system absent from the bindings gets 403
|
||||
as well. The package repeats the system check as `unknown_system` /
|
||||
`wrong_system` for offline evaluation and defence in depth.
|
||||
|
||||
## Rules
|
||||
|
||||
```rego
|
||||
import future.keywords.if
|
||||
import future.keywords.in
|
||||
|
||||
# BEGIN GENERATED catalog_lanes (tools/gen_ops_warden_fetch.py; do not edit by hand)
|
||||
catalog_lanes := {
|
||||
"agent-harness-forgejo-deploy": "railiance-platform",
|
||||
"binky-company-email-imap": "railiance-platform",
|
||||
"binky-qonto-api": "railiance-platform",
|
||||
"email-connect-transactional": "railiance-platform",
|
||||
"forgejo-admin-api-token": "railiance-platform",
|
||||
"issue-core-ingestion-api-key": "railiance-platform",
|
||||
"openrouter-llm-connect": "railiance-platform",
|
||||
"policy-nexus-forgejo-source-read": "railiance-platform",
|
||||
"railiance-backup-offsite-lane": "railiance-platform",
|
||||
"rapp-qonto-keycape-client": "key-cape",
|
||||
"reuse-surface-hub-write-token": "railiance-platform",
|
||||
"whynot-design-npm-publish": "railiance-platform",
|
||||
}
|
||||
# END GENERATED catalog_lanes
|
||||
|
||||
catalog_systems := {system | system := catalog_lanes[_]}
|
||||
|
||||
decision := {"effect": "allow", "reason": "secret_read_lane_matched"} if {
|
||||
allowed
|
||||
} else := {"effect": "deny", "reason": first_denial} if {
|
||||
true
|
||||
}
|
||||
|
||||
allowed if {
|
||||
input.action == "read"
|
||||
input.resource.type == "secret"
|
||||
effective_tenant == "tenant:platform"
|
||||
input.subject.type == "operator"
|
||||
has_subject_id
|
||||
need_known
|
||||
catalog_lanes[need_id] == input.resource.system
|
||||
resource_id_matches_need
|
||||
context_owner_matches
|
||||
}
|
||||
|
||||
default need_id := ""
|
||||
|
||||
need_id := input.context.need_id if {
|
||||
is_string(input.context.need_id)
|
||||
}
|
||||
|
||||
need_known if {
|
||||
need_id != ""
|
||||
catalog_lanes[need_id]
|
||||
}
|
||||
|
||||
has_subject_id if {
|
||||
is_string(input.subject.id)
|
||||
input.subject.id != ""
|
||||
}
|
||||
|
||||
default effective_tenant := ""
|
||||
|
||||
effective_tenant := input.tenant if {
|
||||
is_string(input.tenant)
|
||||
input.tenant != ""
|
||||
} else := input.resource.tenant if {
|
||||
is_string(input.resource.tenant)
|
||||
input.resource.tenant != ""
|
||||
} else := input.subject.tenant if {
|
||||
is_string(input.subject.tenant)
|
||||
input.subject.tenant != ""
|
||||
}
|
||||
|
||||
# warden sends secret:<need_id>, or secret:<need_id>/<domain> when a domain is set.
|
||||
resource_id_matches_need if {
|
||||
not has_domain
|
||||
input.resource.id == sprintf("secret:%s", [need_id])
|
||||
}
|
||||
|
||||
resource_id_matches_need if {
|
||||
has_domain
|
||||
input.resource.id == sprintf("secret:%s/%s", [need_id, input.context.domain])
|
||||
}
|
||||
|
||||
has_domain if {
|
||||
is_string(input.context.domain)
|
||||
input.context.domain != ""
|
||||
}
|
||||
|
||||
context_owner_matches if {
|
||||
input.context.owner_repo == input.resource.system
|
||||
}
|
||||
|
||||
default first_denial := "no_matching_rule"
|
||||
|
||||
first_denial := "wrong_action" if {
|
||||
input.action != "read"
|
||||
} else := "wrong_resource_type" if {
|
||||
input.resource.type != "secret"
|
||||
} else := "wrong_tenant" if {
|
||||
effective_tenant != "tenant:platform"
|
||||
} else := "wrong_subject_type" if {
|
||||
input.subject.type != "operator"
|
||||
} else := "missing_subject" if {
|
||||
not has_subject_id
|
||||
} else := "missing_need_id" if {
|
||||
need_id == ""
|
||||
} else := "unknown_need" if {
|
||||
not need_known
|
||||
} else := "unknown_system" if {
|
||||
not input.resource.system in catalog_systems
|
||||
} else := "wrong_system" if {
|
||||
catalog_lanes[need_id] != input.resource.system
|
||||
} else := "resource_need_mismatch" if {
|
||||
not resource_id_matches_need
|
||||
} else := "owner_repo_mismatch" if {
|
||||
not context_owner_matches
|
||||
}
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```rego test
|
||||
package flexauth.ops_warden.secret_read_test
|
||||
|
||||
import future.keywords.if
|
||||
import data.flexauth.ops_warden.secret_read
|
||||
|
||||
forgejo := {
|
||||
"subject": {"id": "operator", "type": "operator", "tenant": "tenant:platform"},
|
||||
"action": "read",
|
||||
"resource": {
|
||||
"id": "secret:forgejo-admin-api-token",
|
||||
"type": "secret",
|
||||
"system": "railiance-platform",
|
||||
"tenant": "tenant:platform"
|
||||
},
|
||||
"context": {"need_id": "forgejo-admin-api-token", "owner_repo": "railiance-platform", "domain": null}
|
||||
}
|
||||
|
||||
test_forgejo_admin_read_allowed if {
|
||||
secret_read.decision.effect == "allow" with input as forgejo
|
||||
}
|
||||
|
||||
test_domain_suffix_allowed if {
|
||||
secret_read.decision.effect == "allow" with input as object.union(forgejo, {
|
||||
"resource": object.union(forgejo.resource, {"id": "secret:forgejo-admin-api-token/infotech"}),
|
||||
"context": object.union(forgejo.context, {"domain": "infotech"}),
|
||||
})
|
||||
}
|
||||
|
||||
test_unknown_need_denied if {
|
||||
secret_read.decision.reason == "unknown_need" with input as object.union(forgejo, {
|
||||
"resource": object.union(forgejo.resource, {"id": "secret:no-such-need"}),
|
||||
"context": object.union(forgejo.context, {"need_id": "no-such-need"}),
|
||||
})
|
||||
}
|
||||
|
||||
test_unknown_system_denied if {
|
||||
secret_read.decision.reason == "unknown_system" with input as object.union(forgejo, {
|
||||
"resource": object.union(forgejo.resource, {"system": "ops-warden"}),
|
||||
"context": object.union(forgejo.context, {"owner_repo": "ops-warden"}),
|
||||
})
|
||||
}
|
||||
|
||||
test_wrong_action_denied if {
|
||||
secret_read.decision.reason == "wrong_action" with input as object.union(forgejo, {"action": "write"})
|
||||
}
|
||||
|
||||
test_spoofed_domain_denied if {
|
||||
secret_read.decision.reason == "resource_need_mismatch" with input as object.union(forgejo, {
|
||||
"resource": object.union(forgejo.resource, {"id": "secret:forgejo-admin-api-token/other"}),
|
||||
"context": object.union(forgejo.context, {"domain": "infotech"}),
|
||||
})
|
||||
}
|
||||
```
|
||||
256
examples/ops-warden-fetch/registry_snapshot.json
Normal file
256
examples/ops-warden-fetch/registry_snapshot.json
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
{
|
||||
"systems": [
|
||||
{
|
||||
"id": "key-cape",
|
||||
"name": "key-cape secrets read through ops-warden",
|
||||
"resource_types": [
|
||||
{
|
||||
"name": "secret",
|
||||
"scope_level": "Resource",
|
||||
"planes": [
|
||||
"Secret",
|
||||
"Audit"
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"name": "read",
|
||||
"capabilities": [
|
||||
"View",
|
||||
"Use"
|
||||
],
|
||||
"planes": [
|
||||
"Secret",
|
||||
"Audit"
|
||||
],
|
||||
"exposure_modes": [
|
||||
"SecretMaterial"
|
||||
]
|
||||
}
|
||||
],
|
||||
"caring_profiles": [
|
||||
"caring-0.4.0-rc2"
|
||||
],
|
||||
"metadata": {
|
||||
"tenant": "tenant:platform",
|
||||
"conduit": "ops-warden",
|
||||
"generated_by": "tools/gen_ops_warden_fetch.py"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "railiance-platform",
|
||||
"name": "railiance-platform secrets read through ops-warden",
|
||||
"resource_types": [
|
||||
{
|
||||
"name": "secret",
|
||||
"scope_level": "Resource",
|
||||
"planes": [
|
||||
"Secret",
|
||||
"Audit"
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"name": "read",
|
||||
"capabilities": [
|
||||
"View",
|
||||
"Use"
|
||||
],
|
||||
"planes": [
|
||||
"Secret",
|
||||
"Audit"
|
||||
],
|
||||
"exposure_modes": [
|
||||
"SecretMaterial"
|
||||
]
|
||||
}
|
||||
],
|
||||
"caring_profiles": [
|
||||
"caring-0.4.0-rc2"
|
||||
],
|
||||
"metadata": {
|
||||
"tenant": "tenant:platform",
|
||||
"conduit": "ops-warden",
|
||||
"generated_by": "tools/gen_ops_warden_fetch.py"
|
||||
}
|
||||
}
|
||||
],
|
||||
"resource_manifests": [
|
||||
{
|
||||
"id": "ops-warden-fetch-key-cape",
|
||||
"system": "key-cape",
|
||||
"resources": [
|
||||
{
|
||||
"id": "secret:rapp-qonto-keycape-client",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:key-cape",
|
||||
"attributes": {
|
||||
"need_id": "rapp-qonto-keycape-client",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ops-warden-fetch-railiance-platform",
|
||||
"system": "railiance-platform",
|
||||
"resources": [
|
||||
{
|
||||
"id": "secret:agent-harness-forgejo-deploy",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:railiance-platform",
|
||||
"attributes": {
|
||||
"need_id": "agent-harness-forgejo-deploy",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret:binky-company-email-imap",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:railiance-platform",
|
||||
"attributes": {
|
||||
"need_id": "binky-company-email-imap",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret:binky-qonto-api",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:railiance-platform",
|
||||
"attributes": {
|
||||
"need_id": "binky-qonto-api",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret:email-connect-transactional",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:railiance-platform",
|
||||
"attributes": {
|
||||
"need_id": "email-connect-transactional",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret:forgejo-admin-api-token",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:railiance-platform",
|
||||
"attributes": {
|
||||
"need_id": "forgejo-admin-api-token",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret:issue-core-ingestion-api-key",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:railiance-platform",
|
||||
"attributes": {
|
||||
"need_id": "issue-core-ingestion-api-key",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret:openrouter-llm-connect",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:railiance-platform",
|
||||
"attributes": {
|
||||
"need_id": "openrouter-llm-connect",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret:policy-nexus-forgejo-source-read",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:railiance-platform",
|
||||
"attributes": {
|
||||
"need_id": "policy-nexus-forgejo-source-read",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret:railiance-backup-offsite-lane",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:railiance-platform",
|
||||
"attributes": {
|
||||
"need_id": "railiance-backup-offsite-lane",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret:reuse-surface-hub-write-token",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:railiance-platform",
|
||||
"attributes": {
|
||||
"need_id": "reuse-surface-hub-write-token",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "secret:whynot-design-npm-publish",
|
||||
"type": "secret",
|
||||
"labels": [
|
||||
"ops-warden-fetch",
|
||||
"lane:secret"
|
||||
],
|
||||
"owner": "repo:railiance-platform",
|
||||
"attributes": {
|
||||
"need_id": "whynot-design-npm-publish",
|
||||
"catalog_lane": "secret"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tenants": [
|
||||
{
|
||||
"id": "tenant:platform",
|
||||
"name": "Platform Tenant"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -33,6 +33,26 @@ echo "$ops_render" | grep -q 'ops-warden=system:serviceaccount:ops-warden:ops-wa
|
|||
echo "$ops_render" | grep -q production_registry_snapshot.json \
|
||||
|| { echo "ops-warden render omits the production registry" >&2; exit 1; }
|
||||
|
||||
# The fetch pin's digest stays empty until CI builds an image containing
|
||||
# examples/ops-warden-fetch; render it with a placeholder until then.
|
||||
fetch_digest_args=()
|
||||
if grep -Eq '^\s*digest:\s*(""|)\s*$' values/ops-warden-fetch.yaml; then
|
||||
echo "note: values/ops-warden-fetch.yaml has no image digest yet (not deployable); rendering with a placeholder"
|
||||
fetch_digest_args=(--set image.digest=sha256:0000000000000000000000000000000000000000000000000000000000000000)
|
||||
fi
|
||||
echo "helm template values/ops-warden-fetch.yaml"
|
||||
fetch_render="$(helm template flex-auth-ops-warden-fetch charts/flex-auth -f values/ops-warden-fetch.yaml "${fetch_digest_args[@]}" --namespace flex-auth)"
|
||||
echo "$fetch_render" | grep -A1 -- '--caller-auth-mode' | grep -q enforce \
|
||||
|| { echo "ops-warden-fetch caller-auth-mode must be enforce" >&2; exit 1; }
|
||||
for system in railiance-platform key-cape; do
|
||||
echo "$fetch_render" | grep -q "${system}=system:serviceaccount:ops-warden:ops-warden" \
|
||||
|| { echo "ops-warden-fetch render omits the ${system} binding" >&2; exit 1; }
|
||||
done
|
||||
echo "$fetch_render" | grep -q examples/ops-warden-fetch/policy_package.md \
|
||||
|| { echo "ops-warden-fetch render omits its policy package" >&2; exit 1; }
|
||||
python3 tools/gen_ops_warden_fetch.py --check >/dev/null 2>&1 || [ ! -f "${HOME}/ops-warden/registry/routing/catalog.yaml" ] \
|
||||
|| { echo "ops-warden-fetch is out of date with the routing catalog; run tools/gen_ops_warden_fetch.py" >&2; exit 1; }
|
||||
|
||||
canary_render="$(helm template flex-auth-canary charts/flex-auth -f values/stage2-canary.yaml --namespace flex-auth)"
|
||||
if echo "$canary_render" | grep -q tokenreviews; then
|
||||
echo "isolated canary must not create TokenReview RBAC" >&2
|
||||
|
|
|
|||
192
tools/gen_ops_warden_fetch.py
Normal file
192
tools/gen_ops_warden_fetch.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate the ops-warden secret-read pin from ops-warden's routing catalog.
|
||||
|
||||
The flex-auth-ops-warden-fetch pin answers warden's ``check_fetch_policy``
|
||||
(action ``read`` on resource type ``secret``, resource.system = the lane's
|
||||
``owner_repo``). Which (need_id, owner_repo) pairs are readable is not authored
|
||||
here: it is every catalog entry with ``status: active``, ``exec_capable: true``
|
||||
and ``lane: secret``. This script writes that set into three places:
|
||||
|
||||
examples/ops-warden-fetch/registry_snapshot.json systems + one resource per lane
|
||||
examples/ops-warden-fetch/policy_package.md the GENERATED catalog_lanes block
|
||||
values/ops-warden-fetch.yaml one caller binding per owner system
|
||||
|
||||
Usage:
|
||||
python3 tools/gen_ops_warden_fetch.py [--catalog PATH] # write
|
||||
python3 tools/gen_ops_warden_fetch.py [--catalog PATH] --check # exit 1 on drift
|
||||
|
||||
The default catalog is ~/ops-warden/registry/routing/catalog.yaml. The image
|
||||
digest already in the values file is preserved. No secret material is read:
|
||||
the catalog is a pointer layer (ops-warden ADR-0001).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
EXAMPLE_DIR = ROOT / "examples" / "ops-warden-fetch"
|
||||
REGISTRY = EXAMPLE_DIR / "registry_snapshot.json"
|
||||
POLICY = EXAMPLE_DIR / "policy_package.md"
|
||||
VALUES = ROOT / "values" / "ops-warden-fetch.yaml"
|
||||
DEFAULT_CATALOG = Path(os.path.expanduser("~/ops-warden/registry/routing/catalog.yaml"))
|
||||
|
||||
CALLER_PRINCIPAL = "system:serviceaccount:ops-warden:ops-warden"
|
||||
TENANT = "tenant:platform"
|
||||
BEGIN = "# BEGIN GENERATED catalog_lanes"
|
||||
END = "# END GENERATED catalog_lanes"
|
||||
|
||||
|
||||
def readable_lanes(catalog_path: Path) -> list[tuple[str, str]]:
|
||||
doc = yaml.safe_load(catalog_path.read_text())
|
||||
lanes = []
|
||||
for entry in doc.get("entries", []):
|
||||
if entry.get("status") != "active":
|
||||
continue
|
||||
if entry.get("exec_capable") is not True:
|
||||
continue
|
||||
if entry.get("lane") != "secret":
|
||||
continue
|
||||
need_id, owner = entry.get("id"), entry.get("owner_repo")
|
||||
if not need_id or not owner:
|
||||
raise SystemExit(f"catalog entry without id/owner_repo: {entry!r}")
|
||||
lanes.append((need_id, owner))
|
||||
if not lanes:
|
||||
raise SystemExit("catalog yielded no readable lanes; refusing to write an empty policy")
|
||||
return sorted(lanes)
|
||||
|
||||
|
||||
def render_registry(lanes: list[tuple[str, str]]) -> str:
|
||||
systems = sorted({owner for _, owner in lanes})
|
||||
snapshot = {
|
||||
"systems": [
|
||||
{
|
||||
"id": system,
|
||||
"name": f"{system} secrets read through ops-warden",
|
||||
"resource_types": [
|
||||
{"name": "secret", "scope_level": "Resource", "planes": ["Secret", "Audit"]}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"name": "read",
|
||||
"capabilities": ["View", "Use"],
|
||||
"planes": ["Secret", "Audit"],
|
||||
"exposure_modes": ["SecretMaterial"],
|
||||
}
|
||||
],
|
||||
"caring_profiles": ["caring-0.4.0-rc2"],
|
||||
"metadata": {
|
||||
"tenant": TENANT,
|
||||
"conduit": "ops-warden",
|
||||
"generated_by": "tools/gen_ops_warden_fetch.py",
|
||||
},
|
||||
}
|
||||
for system in systems
|
||||
],
|
||||
"resource_manifests": [
|
||||
{
|
||||
"id": f"ops-warden-fetch-{system}",
|
||||
"system": system,
|
||||
"resources": [
|
||||
{
|
||||
"id": f"secret:{need_id}",
|
||||
"type": "secret",
|
||||
"labels": ["ops-warden-fetch", "lane:secret"],
|
||||
"owner": f"repo:{system}",
|
||||
"attributes": {"need_id": need_id, "catalog_lane": "secret"},
|
||||
}
|
||||
for need_id, owner in lanes
|
||||
if owner == system
|
||||
],
|
||||
}
|
||||
for system in systems
|
||||
],
|
||||
"tenants": [{"id": TENANT, "name": "Platform Tenant"}],
|
||||
}
|
||||
return json.dumps(snapshot, indent=2) + "\n"
|
||||
|
||||
|
||||
def render_policy(current: str, lanes: list[tuple[str, str]]) -> str:
|
||||
body = "\n".join(f' "{need_id}": "{owner}",' for need_id, owner in lanes)
|
||||
block = f"{BEGIN} (tools/gen_ops_warden_fetch.py; do not edit by hand)\ncatalog_lanes := {{\n{body}\n}}\n{END}"
|
||||
pattern = re.compile(re.escape(BEGIN) + r".*?" + re.escape(END), re.S)
|
||||
if not pattern.search(current):
|
||||
raise SystemExit(f"{POLICY}: generated markers not found")
|
||||
return pattern.sub(lambda _: block, current, count=1)
|
||||
|
||||
|
||||
def render_values(current: str | None, lanes: list[tuple[str, str]]) -> str:
|
||||
digest = ""
|
||||
if current:
|
||||
match = re.search(r"^\s*digest:\s*(\S*)\s*$", current, re.M)
|
||||
if match:
|
||||
digest = match.group(1).strip("\"'")
|
||||
systems = sorted({owner for _, owner in lanes})
|
||||
bindings = [f"{system}={CALLER_PRINCIPAL}" for system in systems]
|
||||
extra = "".join(f"\n - {b}" for b in bindings[1:]) or " []"
|
||||
return f"""# ops-warden secret-read pin (CUST-WP-0075, access-engine ADHOC-2026-10-03).
|
||||
# Answers warden check_fetch_policy: action read on resource type secret, system =
|
||||
# the lane's owner_repo. Separate from flex-auth-ops-warden (SSH signing) so each
|
||||
# decision class rolls independently (FLEX-WP-0016). Isolated: the workstation
|
||||
# reaches it through the ops-bridge tunnel flex-auth-ops-warden-fetch-railiance01
|
||||
# (127.0.0.1:19091). Every owner system is bound to the ops-warden ServiceAccount,
|
||||
# so only ops-warden may ask; callerAuth enforce as on the signing pin.
|
||||
#
|
||||
# GENERATED by tools/gen_ops_warden_fetch.py from ops-warden's routing catalog,
|
||||
# except image.digest, which the script preserves. Regenerate, do not hand-edit.
|
||||
name: flex-auth-ops-warden-fetch
|
||||
image:
|
||||
repository: forgejo.coulomb.social/coulomb/flex-auth
|
||||
digest: {digest or '""'}
|
||||
args:
|
||||
- serve
|
||||
- --addr
|
||||
- 0.0.0.0:8080
|
||||
- --registry
|
||||
- /opt/flex-auth/examples/ops-warden-fetch/registry_snapshot.json
|
||||
- --policy
|
||||
- /opt/flex-auth/examples/ops-warden-fetch/policy_package.md
|
||||
callerAuth:
|
||||
mode: enforce
|
||||
kubernetesURL: https://10.43.0.1
|
||||
binding: {bindings[0]}
|
||||
extraBindings:{extra}
|
||||
consumer:
|
||||
isolated: true
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
|
||||
parser.add_argument("--check", action="store_true", help="report drift, write nothing")
|
||||
args = parser.parse_args()
|
||||
|
||||
lanes = readable_lanes(args.catalog)
|
||||
values_current = VALUES.read_text() if VALUES.exists() else None
|
||||
outputs = {
|
||||
REGISTRY: render_registry(lanes),
|
||||
POLICY: render_policy(POLICY.read_text(), lanes),
|
||||
VALUES: render_values(values_current, lanes),
|
||||
}
|
||||
drift = [p for p, text in outputs.items() if not p.exists() or p.read_text() != text]
|
||||
if args.check:
|
||||
for path in drift:
|
||||
print(f"drift: {path.relative_to(ROOT)}", file=sys.stderr)
|
||||
print(f"{len(lanes)} readable lanes; {'drift' if drift else 'in sync'}")
|
||||
return 1 if drift else 0
|
||||
for path in drift:
|
||||
path.write_text(outputs[path])
|
||||
print(f"wrote {path.relative_to(ROOT)}")
|
||||
print(f"{len(lanes)} readable lanes across {len({o for _, o in lanes})} owner systems")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
30
values/ops-warden-fetch.yaml
Normal file
30
values/ops-warden-fetch.yaml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# ops-warden secret-read pin (CUST-WP-0075, access-engine ADHOC-2026-10-03).
|
||||
# Answers warden check_fetch_policy: action read on resource type secret, system =
|
||||
# the lane's owner_repo. Separate from flex-auth-ops-warden (SSH signing) so each
|
||||
# decision class rolls independently (FLEX-WP-0016). Isolated: the workstation
|
||||
# reaches it through the ops-bridge tunnel flex-auth-ops-warden-fetch-railiance01
|
||||
# (127.0.0.1:19091). Every owner system is bound to the ops-warden ServiceAccount,
|
||||
# so only ops-warden may ask; callerAuth enforce as on the signing pin.
|
||||
#
|
||||
# GENERATED by tools/gen_ops_warden_fetch.py from ops-warden's routing catalog,
|
||||
# except image.digest, which the script preserves. Regenerate, do not hand-edit.
|
||||
name: flex-auth-ops-warden-fetch
|
||||
image:
|
||||
repository: forgejo.coulomb.social/coulomb/flex-auth
|
||||
digest: ""
|
||||
args:
|
||||
- serve
|
||||
- --addr
|
||||
- 0.0.0.0:8080
|
||||
- --registry
|
||||
- /opt/flex-auth/examples/ops-warden-fetch/registry_snapshot.json
|
||||
- --policy
|
||||
- /opt/flex-auth/examples/ops-warden-fetch/policy_package.md
|
||||
callerAuth:
|
||||
mode: enforce
|
||||
kubernetesURL: https://10.43.0.1
|
||||
binding: key-cape=system:serviceaccount:ops-warden:ops-warden
|
||||
extraBindings:
|
||||
- railiance-platform=system:serviceaccount:ops-warden:ops-warden
|
||||
consumer:
|
||||
isolated: true
|
||||
87
workplans/ADHOC-2026-10-03.md
Normal file
87
workplans/ADHOC-2026-10-03.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
id: FLEX-WP-ADHOC-2026-10-03
|
||||
type: workplan
|
||||
title: "2026-10-03 secret-read evaluator for ops-warden fetch checks"
|
||||
domain: infotech
|
||||
repo: flex-auth
|
||||
status: blocked
|
||||
owner: claude
|
||||
topic_slug: netkingdom
|
||||
created: "2026-10-03"
|
||||
updated: "2026-10-03"
|
||||
---
|
||||
|
||||
# 2026-10-03 — secret-read evaluator for ops-warden fetch checks
|
||||
|
||||
Opened from the-custodian bug `b792c5d0-f1ff-41cf-90a7-5697ef582a41`
|
||||
(CUST-WP-0075, CCR-2026-0031 provisioning; ops-warden side `faf11061`).
|
||||
Founder direction 2026-10-03 (the-custodian `docs/agent-environment-orientation.md`
|
||||
§0): fixing the security layer is wanted; live changes still need his go.
|
||||
|
||||
**Bug.** ops-warden `check_fetch_policy` posts `read` on `secret:<need_id>`
|
||||
with `resource.system = <owner_repo>` to `policy.flex_auth_url`
|
||||
(`127.0.0.1:19090`, the tunnel to `flex-auth-ops-warden`, the SSH signing pin).
|
||||
That pin binds only system `ops-warden`, and its package returns `wrong_system`
|
||||
for anything else, so every non-SSH fetch lane got 403.
|
||||
|
||||
## Build the secret-read evaluator (repo side)
|
||||
|
||||
```task
|
||||
id: FLEX-WP-ADHOC-2026-10-03-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Separate isolated pin `flex-auth-ops-warden-fetch` (one decision class per pin,
|
||||
FLEX-WP-0016):
|
||||
|
||||
- `examples/ops-warden-fetch/`: package `ops-warden.secret.read` v1, registry,
|
||||
15 fixtures, 6 Rego tests, check requests.
|
||||
- `values/ops-warden-fetch.yaml`: enforce; key-cape and railiance-platform bound
|
||||
to the ops-warden ServiceAccount.
|
||||
- `tools/gen_ops_warden_fetch.py`: generates the registry, `catalog_lanes` and
|
||||
the bindings from ops-warden's catalog; `--check` reports drift.
|
||||
- Chart `callerAuth.extraBindings`; `tests/stage1.sh` render and drift check.
|
||||
- `cmd/flex-auth/ops_warden_fetch_test.go`: decisions, caller bindings read from
|
||||
the values file, registry/policy parity.
|
||||
|
||||
2026-10-03: `make ci` green; 12 lanes across 2 owner systems.
|
||||
|
||||
## Deploy the pin (founder go required)
|
||||
|
||||
```task
|
||||
id: FLEX-WP-ADHOC-2026-10-03-T02
|
||||
status: wait
|
||||
priority: high
|
||||
needs_human: true
|
||||
blocking_reason: "Live railiance01 change; founder go needed for CI image digest, helm install and tunnel."
|
||||
```
|
||||
|
||||
Steps, none run yet:
|
||||
|
||||
1. Push `main`; Forgejo `image.yaml` builds (trigger `examples/**`). Take the
|
||||
digest from "Report immutable digest" and set `image.digest` in
|
||||
`values/ops-warden-fetch.yaml` (the generator preserves it). Commit.
|
||||
2. Preflight on railiance01: CPU headroom
|
||||
(`kubectl describe node | grep -A4 "Allocated resources"`; the new pod
|
||||
requests 25m CPU / 32Mi), then a server dry-run of
|
||||
`helm template flex-auth-ops-warden-fetch charts/flex-auth -f values/ops-warden-fetch.yaml --namespace flex-auth`.
|
||||
3. Install on the node (`export KUBECONFIG=/etc/rancher/k3s/k3s.yaml`; chart and
|
||||
values copied with scp):
|
||||
`helm upgrade --install flex-auth-ops-warden-fetch charts/flex-auth -f values/ops-warden-fetch.yaml --namespace flex-auth --atomic`.
|
||||
`flex-auth-ops-warden` (signing) is not touched.
|
||||
4. Read the new Service ClusterIP (`kubectl -n flex-auth get svc flex-auth-ops-warden-fetch`).
|
||||
5. Workstation tunnel in ops-bridge (`~/.config/bridge/tunnels.yaml`), modelled on
|
||||
`flex-auth-ops-warden-railiance01`: name `flex-auth-ops-warden-fetch-railiance01`,
|
||||
host `92.205.62.239`, `remote_host: <ClusterIP>`, `remote_port: 8080`,
|
||||
`local_port: 19091`, health check `http://127.0.0.1:19091/healthz`; then
|
||||
`bridge up flex-auth-ops-warden-fetch-railiance01`.
|
||||
6. Probe with an ops-warden ServiceAccount token minted for audience `flex-auth`
|
||||
(as in FLEX-WP-0016-T03):
|
||||
`check_request_allow_forgejo_admin_api_token.json` → 200 allow;
|
||||
`check_request_deny_unknown_need.json` → 200 deny `unknown_need`;
|
||||
no token → 401; `resource.system: ops-warden` → 403.
|
||||
7. ops-warden points its fetch checks at `http://127.0.0.1:19091`, separate from
|
||||
the signing URL `:19090` (its side: WARDEN-WP-0039-T03).
|
||||
|
||||
Close-down checklist row 1 (the-custodian) is updated by message, not by edit.
|
||||
Loading…
Add table
Add a link
Reference in a new issue