flex-auth/internal/adapters/tenantengine/http_client.go

66 lines
1.9 KiB
Go
Raw Normal View History

FLEX-WP-0008 T03-T04: tenant-engine live-lookup context adapter, close internal/adapters/tenantengine: HTTPClient.LiveRoles() calls tenant-engine's GET /tenants/{id}/roles/live. LiveRolesResult.Available is the load-bearing field -- false on any transport error, non-200, or malformed body, never inferred as zero roles. AttachToContext() writes both tenant_roles and tenant_roles_available into a CheckRequest.Context map; a consuming Rego policy must check tenant_roles_available before trusting tenant_roles. Architectural finding recorded in the workplan: engine.go's Check() has no context-adapter hook, and none of the existing topaz/relationship/rule adapters are wired into cmd/flex-auth either -- they're standalone packages for downstream composition. This adapter is a request-preparation helper a protected system's own request-building code calls before POST /v1/check, not an engine-internal hook, matching that precedent exactly. 9 Go tests; gofmt/vet/build clean; go test ./... green repo-wide. Verified as a real three-service chain: live flex-auth serve + live tenant-engine, created a tenant and granted it a CUS role through the real flex-auth-gated write path, then read it back through this adapter (via a throwaway harness, not committed) -- known tenant: roles=[CUS] available=true; unknown tenant: roles=[] available=false err="status 404". FLEX-WP-0008 closed: T01-T04 all done. tenant-engine's write path is now real end-to-end; any other protected system can pull live tenant capability role context, fail-closed. Remaining open item unchanged from TEN-WP-0003: KEY-WP-0005 (key-cape's IAM Profile core-claims gap). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 23:33:34 +02:00
package tenantengine
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// HTTPClient calls tenant-engine's live-lookup endpoint
// (GET /tenants/{id}/roles/live).
type HTTPClient struct {
BaseURL string
Client *http.Client
}
// NewHTTPClient creates an HTTP-backed tenant-engine client.
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
if baseURL == "" {
return nil, fmt.Errorf("tenant-engine base URL is required")
}
return &HTTPClient{
BaseURL: strings.TrimRight(baseURL, "/"),
Client: &http.Client{Timeout: 3 * time.Second},
}, nil
}
// LiveRoles calls GET /tenants/{tenantID}/roles/live.
//
// Fail-closed by construction: any transport error, non-200 response, or
// malformed body returns LiveRolesResult{Available: false} alongside a
// non-nil error. Nothing is inferred as "zero roles" from a failure --
// callers must check Available, not just the length of Roles.
func (c *HTTPClient) LiveRoles(ctx context.Context, tenantID string) (LiveRolesResult, error) {
url := fmt.Sprintf("%s/tenants/%s/roles/live", c.BaseURL, tenantID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return LiveRolesResult{Available: false}, NewBackendError(FailureUnavailable, "live_roles", err)
}
resp, err := c.Client.Do(req)
if err != nil {
return LiveRolesResult{Available: false}, NewBackendError(FailureUnavailable, "live_roles", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return LiveRolesResult{Available: false}, NewBackendError(
FailureUnavailable, "live_roles", fmt.Errorf("status %d", resp.StatusCode),
)
}
var body struct {
TenantID string `json:"tenant_id"`
Roles []string `json:"roles"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return LiveRolesResult{Available: false}, NewBackendError(FailureInvalidResponse, "live_roles", err)
}
return LiveRolesResult{Roles: body.Roles, Available: true}, nil
}