KEY-WP-0005-T02-T03: cached tenant_roles claim, close workplan
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m21s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 1m21s
New internal/adapters/tenantengine package, mirroring
internal/adapters/{lldap,privacyidea,authelia}'s shape: Client.Roles()
calls tenant-engine's cache-read endpoint. Fails open by construction --
unreachable, non-200, malformed body, or a nil *Client all return
(nil, false), never an error to specially handle. Wired into
TokenHandler.TenantEngine (nil by default, existing tests unaffected);
token.go stamps tenant_roles only when ok.
7 adapter tests plus 3 TokenHandler-level tests proving the actual
required behavior end-to-end: present when reachable, token issuance still
200 with every other core claim intact when unreachable (tenant_roles
simply absent -- the literal done-criteria), absent when not configured.
Real bug found and fixed at the source, not worked around: the first live
cross-process check (real flex-auth, real tenant-engine, this adapter)
returned tenant_not_found for a tenant that existed -- tenant-engine's read
endpoint was keyed by its internal tenant_id, but key-cape only ever has
the tenant's profile identifier. Fixed in tenant-engine
(ADHOC-2026-07-24), re-verified with the same live three-process chain --
roles=[IAM] ok=true.
Workplan closed: T01-T03 done. Explicitly still open: client_credentials /
service-token issuance -- no such flow exists in token.go at all, a
materially larger separate piece of work than either task here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
fb888579dc
commit
44da5f5f99
5 changed files with 375 additions and 4 deletions
74
src/internal/adapters/tenantengine/adapter.go
Normal file
74
src/internal/adapters/tenantengine/adapter.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Package tenantengine calls tenant-engine's cache-read endpoint at
|
||||
// token-issuance time to source the optional tenant_roles claim
|
||||
// (net-kingdom/canon/standards/iam-profile_v0.3.md, "Tenant Roles").
|
||||
//
|
||||
// This is the cache-read direction only, and it fails OPEN -- the opposite
|
||||
// of flex-auth's live-lookup adapter (flex-auth/internal/adapters/tenantengine),
|
||||
// which must fail closed. tenant_roles is documented as a cache callers
|
||||
// must never trust for privileged decisions (flex-auth re-validates live
|
||||
// before authorizing aal2-class actions); losing this claim at issuance
|
||||
// time is a performance regression, not a security one. Blocking login
|
||||
// because a cache source is briefly down would be the wrong trade.
|
||||
package tenantengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client fetches cached capability roles for a tenant.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// New returns a tenant-engine cache-read client. A nil httpClient gets a
|
||||
// short default timeout -- this call sits on the synchronous token-issuance
|
||||
// path and must not turn a cache miss into a slow login.
|
||||
func New(baseURL string, httpClient *http.Client) *Client {
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 2 * time.Second}
|
||||
}
|
||||
return &Client{BaseURL: strings.TrimRight(baseURL, "/"), HTTP: httpClient}
|
||||
}
|
||||
|
||||
// Roles fetches GET /tenants/{tenantID}/roles.
|
||||
//
|
||||
// Returns (nil, false) -- not an error -- on any failure: unreachable
|
||||
// tenant-engine, non-200 response, or a malformed body. Callers must treat
|
||||
// false as "omit the tenant_roles claim entirely", never as "emit an empty
|
||||
// or stale role list".
|
||||
func (c *Client) Roles(ctx context.Context, tenantID string) ([]string, bool) {
|
||||
if c == nil || c.BaseURL == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/tenants/%s/roles", c.BaseURL, tenantID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return body.Roles, true
|
||||
}
|
||||
107
src/internal/adapters/tenantengine/adapter_test.go
Normal file
107
src/internal/adapters/tenantengine/adapter_test.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package tenantengine_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"keycape/internal/adapters/tenantengine"
|
||||
)
|
||||
|
||||
func TestRolesReturnsRolesOnSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/tenants/tenant:friendly:binky/roles" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"tenant_id":"tenant:friendly:binky","roles":["CUS","VEN"]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := tenantengine.New(server.URL, nil)
|
||||
roles, ok := client.Roles(context.Background(), "tenant:friendly:binky")
|
||||
|
||||
if !ok {
|
||||
t.Fatal("expected ok = true")
|
||||
}
|
||||
if len(roles) != 2 || roles[0] != "CUS" || roles[1] != "VEN" {
|
||||
t.Fatalf("unexpected roles: %v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolesFailsOpenOnNon200(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := tenantengine.New(server.URL, nil)
|
||||
roles, ok := client.Roles(context.Background(), "tenant:friendly:binky")
|
||||
|
||||
if ok {
|
||||
t.Fatal("expected ok = false on a 503")
|
||||
}
|
||||
if roles != nil {
|
||||
t.Fatalf("expected nil roles, got %v", roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolesFailsOpenOnMalformedBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("not json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := tenantengine.New(server.URL, nil)
|
||||
_, ok := client.Roles(context.Background(), "tenant:friendly:binky")
|
||||
|
||||
if ok {
|
||||
t.Fatal("expected ok = false on malformed body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolesFailsOpenOnConnectionFailure(t *testing.T) {
|
||||
client := tenantengine.New("http://127.0.0.1:1", nil)
|
||||
_, ok := client.Roles(context.Background(), "tenant:friendly:binky")
|
||||
|
||||
if ok {
|
||||
t.Fatal("expected ok = false on connection failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolesFailsOpenOnTimeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"roles":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := tenantengine.New(server.URL, &http.Client{Timeout: 10 * time.Millisecond})
|
||||
_, ok := client.Roles(context.Background(), "tenant:friendly:binky")
|
||||
|
||||
if ok {
|
||||
t.Fatal("expected ok = false on timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolesFailsOpenOnNilClient(t *testing.T) {
|
||||
var client *tenantengine.Client
|
||||
roles, ok := client.Roles(context.Background(), "tenant:friendly:binky")
|
||||
|
||||
if ok || roles != nil {
|
||||
t.Fatal("expected a nil *Client to fail open safely, not panic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRolesFailsOpenOnEmptyBaseURL(t *testing.T) {
|
||||
client := tenantengine.New("", nil)
|
||||
_, ok := client.Roles(context.Background(), "tenant:friendly:binky")
|
||||
|
||||
if ok {
|
||||
t.Fatal("expected ok = false with an empty base URL")
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"keycape/internal/adapters/tenantengine"
|
||||
"keycape/internal/domain"
|
||||
profileerrors "keycape/internal/errors"
|
||||
"keycape/internal/server/telemetry"
|
||||
|
|
@ -25,6 +26,9 @@ type TokenHandler struct {
|
|||
Issuer string
|
||||
TokenLifetime time.Duration
|
||||
Emitter telemetry.Emitter
|
||||
// TenantEngine sources the optional tenant_roles claim (KEY-WP-0005-T02).
|
||||
// Nil disables it entirely -- token issuance never depends on it.
|
||||
TenantEngine *tenantengine.Client
|
||||
}
|
||||
|
||||
// tokenResponse is the JSON body returned on a successful token exchange.
|
||||
|
|
@ -130,12 +134,20 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
// Core claims required by net-kingdom/canon/standards/iam-profile_v0.3.md
|
||||
// for every production token -- not scope-gated, unlike the recommended
|
||||
// human claims above (KEY-WP-0005-T01).
|
||||
claims["tenant"] = effectiveTenant(user)
|
||||
tenant := effectiveTenant(user)
|
||||
claims["tenant"] = tenant
|
||||
claims["principal_type"] = "human"
|
||||
claims["groups"] = nonNilStrings(user.Groups)
|
||||
claims["roles"] = nonNilStrings(user.Roles)
|
||||
claims["assurance"] = assuranceClaim(sess.MFAVerified, now)
|
||||
|
||||
// Optional cached tenant_roles claim (KEY-WP-0005-T02). Fails open --
|
||||
// see internal/adapters/tenantengine's package doc for why this is the
|
||||
// one place in the whole tenant_roles design where that's correct.
|
||||
if roles, ok := h.TenantEngine.Roles(ctx, tenant); ok {
|
||||
claims["tenant_roles"] = roles
|
||||
}
|
||||
|
||||
// 7. Sign JWT with RSA-SHA256.
|
||||
kid := "key-1" // static kid for v0.1
|
||||
jwtToken, err := buildJWT(claims, kid, h.SigningKey)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"keycape/internal/adapters/tenantengine"
|
||||
"keycape/internal/domain"
|
||||
profileerrors "keycape/internal/errors"
|
||||
"keycape/internal/server/oidc"
|
||||
|
|
@ -507,3 +508,117 @@ func TestTokenHandler_CodeDeletedAfterUse(t *testing.T) {
|
|||
t.Errorf("second use: expected 400 (code replay), got %d", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KEY-WP-0005-T02: cached tenant_roles claim
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTokenHandler_TenantRoles_PresentWhenTenantEngineReachable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"tenant_id":"tenant:coulomb","roles":["IAM","VEN"]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
sessions := oidc.NewSessionStore()
|
||||
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
||||
h, _ := newTokenHandler(t, sessions, users)
|
||||
h.TenantEngine = tenantengine.New(server.URL, nil)
|
||||
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code := seededSession(sessions, verifier)
|
||||
|
||||
params := url.Values{
|
||||
"grant_type": []string{"authorization_code"},
|
||||
"code": []string{code},
|
||||
"client_id": []string{"test-client"},
|
||||
"redirect_uri": []string{"https://app.example.com/callback"},
|
||||
"code_verifier": []string{verifier},
|
||||
}
|
||||
req := tokenRequest(params)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
resp := decodeTokenResponse(t, w.Body.String())
|
||||
claims := parseJWTPayload(t, resp["id_token"].(string))
|
||||
|
||||
roles, ok := claims["tenant_roles"].([]interface{})
|
||||
if !ok || len(roles) != 2 || roles[0] != "IAM" || roles[1] != "VEN" {
|
||||
t.Errorf("tenant_roles: want [IAM VEN], got %v", claims["tenant_roles"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenHandler_TenantRoles_OmittedButIssuanceSucceedsWhenUnreachable(t *testing.T) {
|
||||
sessions := oidc.NewSessionStore()
|
||||
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
||||
h, _ := newTokenHandler(t, sessions, users)
|
||||
// Unreachable: nothing listens on this port.
|
||||
h.TenantEngine = tenantengine.New("http://127.0.0.1:1", nil)
|
||||
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code := seededSession(sessions, verifier)
|
||||
|
||||
params := url.Values{
|
||||
"grant_type": []string{"authorization_code"},
|
||||
"code": []string{code},
|
||||
"client_id": []string{"test-client"},
|
||||
"redirect_uri": []string{"https://app.example.com/callback"},
|
||||
"code_verifier": []string{verifier},
|
||||
}
|
||||
req := tokenRequest(params)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
// Token issuance itself must not fail just because the cache source is
|
||||
// down -- this is the literal point of KEY-WP-0005-T02's fail-open rule.
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 even with tenant-engine unreachable, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
resp := decodeTokenResponse(t, w.Body.String())
|
||||
claims := parseJWTPayload(t, resp["id_token"].(string))
|
||||
|
||||
if _, present := claims["tenant_roles"]; present {
|
||||
t.Errorf("tenant_roles must be omitted (not null, not []), got %v", claims["tenant_roles"])
|
||||
}
|
||||
// Every other core claim must still be present -- one missing optional
|
||||
// claim must not cascade into missing required ones.
|
||||
for _, c := range []string{"tenant", "principal_type", "groups", "roles", "assurance"} {
|
||||
if _, ok := claims[c]; !ok {
|
||||
t.Errorf("required claim %q missing even though only tenant_roles should be affected", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenHandler_TenantRoles_OmittedWhenTenantEngineNotConfigured(t *testing.T) {
|
||||
sessions := oidc.NewSessionStore()
|
||||
users := &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}}
|
||||
h, _ := newTokenHandler(t, sessions, users)
|
||||
// h.TenantEngine left nil -- must not panic, must simply omit the claim.
|
||||
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code := seededSession(sessions, verifier)
|
||||
|
||||
params := url.Values{
|
||||
"grant_type": []string{"authorization_code"},
|
||||
"code": []string{code},
|
||||
"client_id": []string{"test-client"},
|
||||
"redirect_uri": []string{"https://app.example.com/callback"},
|
||||
"code_verifier": []string{verifier},
|
||||
}
|
||||
req := tokenRequest(params)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
||||
}
|
||||
resp := decodeTokenResponse(t, w.Body.String())
|
||||
claims := parseJWTPayload(t, resp["id_token"].(string))
|
||||
|
||||
if _, present := claims["tenant_roles"]; present {
|
||||
t.Errorf("tenant_roles must be omitted when TenantEngine is nil, got %v", claims["tenant_roles"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ type: workplan
|
|||
title: "IAM Profile core claims: tenant, principal_type, groups, roles, assurance, tenant_roles"
|
||||
domain: infotech
|
||||
repo: key-cape
|
||||
status: ready
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
created: "2026-07-23"
|
||||
|
|
@ -139,7 +139,7 @@ human included.
|
|||
|
||||
```task
|
||||
id: KEY-WP-0005-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1b8f44b4-2763-4d5a-a3eb-1c2b9a25dd15"
|
||||
```
|
||||
|
|
@ -165,11 +165,44 @@ Done when: token issuance still succeeds with `tenant_roles` omitted when
|
|||
`tenant-engine` is unreachable (test simulates the outage); present and
|
||||
correct when reachable, for a tenant with known role grants.
|
||||
|
||||
**Done 2026-07-24:** New `internal/adapters/tenantengine` package
|
||||
(`Client.Roles(ctx, tenantID) ([]string, bool)`), mirroring
|
||||
`internal/adapters/{lldap,privacyidea,authelia}`'s shape exactly. Fails
|
||||
open by construction — unreachable, non-200, or malformed body all return
|
||||
`(nil, false)`, never an error the caller has to specially handle; a `nil
|
||||
*Client` also fails open safely rather than panicking, so `TokenHandler`
|
||||
doesn't need a separate "is this configured" branch. Wired into
|
||||
`TokenHandler.TenantEngine` (nil by default — existing tests and call
|
||||
sites are unaffected); `token.go` stamps `tenant_roles` only when `ok`.
|
||||
|
||||
7 adapter-level tests (success, non-200, malformed body, connection
|
||||
failure, timeout, nil client, empty base URL). 3 new `token_test.go` cases
|
||||
prove the actual required behavior end-to-end through `TokenHandler`, not
|
||||
just at the adapter layer: present and correct when reachable; **token
|
||||
issuance still returns 200 with every other required core claim intact**
|
||||
when unreachable, `tenant_roles` simply absent (the literal done-criteria);
|
||||
absent when `TenantEngine` isn't configured at all. `go build`/`vet`
|
||||
clean, `go test ./...` green repo-wide.
|
||||
|
||||
**A real bug found and fixed along the way, not worked around:** the first
|
||||
live cross-process check (real `flex-auth`, real `tenant-engine`, this
|
||||
adapter) returned `tenant_not_found` for a tenant that genuinely existed.
|
||||
`tenant-engine`'s `GET /tenants/{tenant_id}/roles` was keyed by its
|
||||
internal `tenant_id`, but `key-cape` (like any external caller) only ever
|
||||
has the tenant's profile *identifier* (the `tenant` claim value) — it has
|
||||
no way to know tenant-engine's internal id. Fixed at the source:
|
||||
`tenant-engine/workplans/ADHOC-2026-07-24.md` (identifier-or-id resolution
|
||||
added to `InMemoryTenantStore`), re-verified with the exact same live
|
||||
three-process chain afterward — `roles=[IAM] ok=true`, resolved by
|
||||
identifier. This is exactly why T02 insisted on a live-server-reachable
|
||||
test rather than only an `httptest` fake: the fake would have used
|
||||
whatever id shape the test author happened to pick and never caught this.
|
||||
|
||||
## Task: Closure review
|
||||
|
||||
```task
|
||||
id: KEY-WP-0005-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: low
|
||||
state_hub_task_id: "34555bf7-0f18-4dac-ade2-6ff3738f353f"
|
||||
```
|
||||
|
|
@ -180,3 +213,33 @@ producing conformant tokens, and unblocks `flex-auth`'s side of the
|
|||
`tenant_roles` picture receiving a real claim to reason about (though
|
||||
`flex-auth`'s live-lookup path doesn't depend on this claim existing — only
|
||||
the cache-read/performance path does). Run `statehub fix-consistency`.
|
||||
|
||||
**Closed 2026-07-24.** T01–T02 done. `go build ./...`, `go vet ./...` clean;
|
||||
`go test ./...` green across the whole repo. Python
|
||||
`net-kingdom/tools/iam-profile-conformance` not run against a live instance
|
||||
(needs the full Authelia+LLDAP+privacyIDEA stack, impractical in this
|
||||
pass) — real coverage instead came from `tests/profile`'s own full HTTP
|
||||
integration test (`TestCompleteTokenFlow`) with genuine claim-value
|
||||
assertions, and from three separate live cross-process checks against a
|
||||
real `tenant-engine` (one of which found and fixed a real bug in
|
||||
`tenant-engine` itself, `ADHOC-2026-07-24`).
|
||||
|
||||
**What this closes:** `key-cape`'s human Authorization Code + PKCE flow now
|
||||
emits every IAM Profile v0.3 core claim (`tenant`, `principal_type`,
|
||||
`groups`, `roles`, `assurance`) plus the optional `tenant_roles` cache,
|
||||
correctly sourced from a real `assurance` signal (MFA actually verified
|
||||
this session, not static enrollment) and a real `tenant-engine` call
|
||||
(fail-open, never blocking login). `KEY-WP-0004`'s Binky onboarding can now
|
||||
produce conformant tokens for human logins.
|
||||
|
||||
**What stays explicitly open, not silently dropped:**
|
||||
- `client_credentials` / service-token issuance — no such flow exists in
|
||||
`token.go` at all yet (confirmed in T01); needed before any *service*
|
||||
caller (not a human) can get a token with `principal_type: "service"` and
|
||||
its own `tenant`/`tenant_roles`. A materially larger, separate piece of
|
||||
work than either task in this workplan.
|
||||
- `flex-auth`'s side of `tenant_roles` (`FLEX-WP-0008`, already closed
|
||||
separately) doesn't depend on this claim existing at all — its
|
||||
live-lookup path calls `tenant-engine` directly, so this workplan doesn't
|
||||
block it, but also doesn't complete the full `tenant_roles` picture on
|
||||
its own until service tokens exist too.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue