feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
// keycape is the main server binary for the KeyCape IAM profile service.
|
|
|
|
|
// It orchestrates Authelia, LLDAP, and privacyIDEA to implement the
|
|
|
|
|
// NetKingdom IAM Profile (OIDC/PKCE Authorization Code Flow).
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-13 02:18:36 +01:00
|
|
|
"context"
|
|
|
|
|
"crypto/rsa"
|
|
|
|
|
"crypto/x509"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"encoding/pem"
|
|
|
|
|
"flag"
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
"fmt"
|
2026-03-13 02:18:36 +01:00
|
|
|
"net/http"
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
"os"
|
2026-03-13 02:18:36 +01:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/rs/zerolog"
|
|
|
|
|
|
|
|
|
|
"keycape/internal/adapters/authelia"
|
|
|
|
|
"keycape/internal/adapters/lldap"
|
|
|
|
|
"keycape/internal/adapters/privacyidea"
|
2026-09-08 08:59:40 +02:00
|
|
|
"keycape/internal/adapters/tenantengine"
|
2026-09-05 01:08:58 +02:00
|
|
|
"keycape/internal/authclient"
|
2026-03-13 02:18:36 +01:00
|
|
|
"keycape/internal/config"
|
|
|
|
|
"keycape/internal/domain"
|
|
|
|
|
servererrors "keycape/internal/server/errors"
|
|
|
|
|
"keycape/internal/server/oidc"
|
|
|
|
|
"keycape/internal/server/telemetry"
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
)
|
|
|
|
|
|
2026-03-13 02:18:36 +01:00
|
|
|
const version = "0.1.0"
|
|
|
|
|
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
func main() {
|
2026-09-05 01:08:58 +02:00
|
|
|
if len(os.Args) > 1 && (os.Args[1] == "login" || os.Args[1] == "service-token") {
|
|
|
|
|
if err := authclient.Run(context.Background(), os.Args[1:], os.Stderr); err != nil {
|
|
|
|
|
fmt.Fprintln(os.Stderr, err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-13 02:18:36 +01:00
|
|
|
log := zerolog.New(os.Stdout).With().Timestamp().Logger()
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 1. Parse flags and load config.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
var cfgPath string
|
|
|
|
|
flag.StringVar(&cfgPath, "config", "", "path to YAML config file (env: KEYCAPE_CONFIG)")
|
|
|
|
|
flag.Parse()
|
|
|
|
|
|
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Error().Err(err).Msg("failed to load config")
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 2. Validate config.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
errs := config.ValidateConfig(cfg)
|
|
|
|
|
if len(errs) > 0 {
|
|
|
|
|
log.Error().Strs("errors", errs).Msg("config validation failed")
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 3. Load RSA private key.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
privateKey, err := loadPrivateKey(cfg.PrivateKeyPEM)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Error().Err(err).Str("path", cfg.PrivateKeyPEM).Msg("failed to load private key")
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 4. Build JWKS from public key.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
ks := oidc.NewKeySet()
|
|
|
|
|
ks.AddKey("key-1", &privateKey.PublicKey)
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 5. Build client registry.
|
|
|
|
|
// -----------------------------------------------------------------
|
2026-07-27 20:03:07 +02:00
|
|
|
clients, err := buildClientRegistry(cfg.Clients)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Error().Err(err).Msg("failed to build client registry")
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
2026-03-13 02:18:36 +01:00
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 6. Create adapters.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
lldapAdapter := lldap.New(cfg.LLDAP)
|
|
|
|
|
autheliaAdapter := authelia.New(cfg.Authelia, nil)
|
|
|
|
|
privacyIDEAAdapter := privacyidea.New(cfg.PrivacyIDEA, nil)
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 7. Create telemetry emitter.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
emitter := telemetry.NewLogEmitter(log)
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 8. Create enforcement registry.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
enforcement := servererrors.DefaultRegistry()
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 9. Create session store.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
sessions := oidc.NewSessionStore()
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 10. Parse token lifetime.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
tokenLifetime := 15 * time.Minute
|
|
|
|
|
if cfg.TokenLifetime != "" {
|
|
|
|
|
d, err := time.ParseDuration(cfg.TokenLifetime)
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Error().Err(err).Str("tokenLifetime", cfg.TokenLifetime).Msg("invalid tokenLifetime")
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
tokenLifetime = d
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 11. Build issuer base URL.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
issuer := strings.TrimRight(cfg.Issuer, "/")
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 12. Register HTTP handlers.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
|
|
|
|
// Discovery.
|
|
|
|
|
mux.Handle("/.well-known/openid-configuration", oidc.NewDiscoveryHandler(oidc.DiscoveryConfig{
|
|
|
|
|
Issuer: issuer,
|
|
|
|
|
AuthorizationEndpoint: issuer + "/authorize",
|
|
|
|
|
TokenEndpoint: issuer + "/token",
|
|
|
|
|
JWKSUri: issuer + "/jwks",
|
|
|
|
|
UserinfoEndpoint: issuer + "/userinfo",
|
2026-08-16 01:05:27 +02:00
|
|
|
EndSessionEndpoint: issuer + "/logout",
|
Reconcile the canonical model and discovery with the runtime
Closes gap G02 of the scope assessment for the client-registration and discovery
surface. spec/canonical-model.yaml and domain/model.go both claimed to be the
source of truth and disagreed: the spec restricted grants to authorization_code,
required redirect URIs of every client, and omitted the audience, service
subject, tenant, role, MFA and handoff fields the runtime reads.
The durable part is the link, not the edit. A two-way conformance test compares
the spec against the Go model by reflection and fails when a runtime field has
no spec entry or a spec entry is not read by the runtime, the latter unless
marked runtime: false. It found drift beyond the assessment's list on its first
run -- User.tenant was undeclared -- which is the argument for the check over a
one-time reconciliation.
Discovery now advertises the core profile claims that appear on every token and
derives scopes_supported from the registered clients rather than a fixed list.
The Go model is stated as the runtime authority and the YAML as the reviewed
contract, in both files. This covers client registration and discovery, not
schema enforcement in general, which remains G06.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-07 00:22:49 +02:00
|
|
|
Clients: clients,
|
2026-03-13 02:18:36 +01:00
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// JWKS.
|
|
|
|
|
mux.Handle("/jwks", oidc.NewJWKSHandler(ks))
|
|
|
|
|
|
|
|
|
|
// Authorize handler (with enforcement middleware).
|
2026-08-16 01:05:27 +02:00
|
|
|
logins := oidc.NewLoginSessionStore()
|
2026-03-13 02:18:36 +01:00
|
|
|
authorizeHandler := &oidc.AuthorizeHandler{
|
|
|
|
|
ClientConfig: clients,
|
|
|
|
|
Auth: autheliaAdapter,
|
|
|
|
|
MFA: privacyIDEAAdapter,
|
|
|
|
|
Sessions: sessions,
|
2026-08-16 01:05:27 +02:00
|
|
|
Logins: logins,
|
|
|
|
|
Handoffs: oidc.NewHandoffStore(),
|
|
|
|
|
Issuer: issuer,
|
2026-03-13 02:18:36 +01:00
|
|
|
Emitter: emitter,
|
|
|
|
|
}
|
|
|
|
|
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
|
|
|
|
|
mux.Handle("/authorize/callback", authorizeHandler)
|
2026-08-16 01:05:27 +02:00
|
|
|
mux.Handle("/authorize/return", authorizeHandler)
|
|
|
|
|
mux.Handle("/authorize/register", authorizeHandler)
|
|
|
|
|
mux.Handle("/logout", &oidc.LogoutHandler{
|
|
|
|
|
ClientConfig: clients,
|
|
|
|
|
Logins: logins,
|
|
|
|
|
SecureCookie: strings.HasPrefix(strings.ToLower(issuer), "https://"),
|
|
|
|
|
})
|
2026-03-13 02:18:36 +01:00
|
|
|
|
|
|
|
|
// Token handler (with enforcement middleware).
|
|
|
|
|
tokenHandler := &oidc.TokenHandler{
|
|
|
|
|
ClientConfig: clients,
|
|
|
|
|
Sessions: sessions,
|
|
|
|
|
Users: lldapAdapter,
|
2026-07-27 20:03:07 +02:00
|
|
|
SigningKey: privateKey,
|
2026-03-13 02:18:36 +01:00
|
|
|
Issuer: issuer,
|
|
|
|
|
TokenLifetime: tokenLifetime,
|
|
|
|
|
Emitter: emitter,
|
2026-09-08 08:59:40 +02:00
|
|
|
// Opt-in: nil unless tenantEngine.baseURL is configured, which is what
|
|
|
|
|
// leaves tenant_roles off the stock server (KEY-WP-0024). The adapter
|
|
|
|
|
// fails open, so a configured-but-unreachable cache omits the claim
|
|
|
|
|
// rather than failing issuance.
|
|
|
|
|
TenantEngine: buildTenantEngine(cfg.TenantEngine),
|
2026-03-13 02:18:36 +01:00
|
|
|
}
|
|
|
|
|
mux.Handle("/token", enforcement.Middleware(tokenHandler))
|
|
|
|
|
|
|
|
|
|
// Userinfo handler.
|
|
|
|
|
mux.Handle("/userinfo", &oidc.UserinfoHandler{
|
2026-07-27 20:03:07 +02:00
|
|
|
Users: lldapAdapter,
|
2026-03-13 02:18:36 +01:00
|
|
|
SigningKey: &privateKey.PublicKey,
|
2026-07-27 20:03:07 +02:00
|
|
|
Issuer: issuer,
|
|
|
|
|
Emitter: emitter,
|
2026-03-13 02:18:36 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Healthz.
|
|
|
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
|
|
|
"status": "ok",
|
|
|
|
|
"version": version,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Inject emitter into request context.
|
|
|
|
|
handler := withEmitter(mux, emitter)
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
// 13. Start HTTP server.
|
|
|
|
|
// -----------------------------------------------------------------
|
|
|
|
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
|
|
|
|
if cfg.Port == 0 {
|
|
|
|
|
addr = ":8080"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
log.Info().
|
|
|
|
|
Str("issuer", issuer).
|
|
|
|
|
Str("addr", addr).
|
|
|
|
|
Str("environment", cfg.Environment).
|
|
|
|
|
Str("version", version).
|
|
|
|
|
Msg("starting keycape server")
|
|
|
|
|
|
|
|
|
|
srv := &http.Server{
|
|
|
|
|
Addr: addr,
|
|
|
|
|
Handler: handler,
|
|
|
|
|
ReadTimeout: 30 * time.Second,
|
|
|
|
|
WriteTimeout: 30 * time.Second,
|
|
|
|
|
IdleTimeout: 120 * time.Second,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
|
|
|
log.Error().Err(err).Msg("server error")
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Helpers
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// loadPrivateKey reads a PEM file and parses the RSA private key.
|
|
|
|
|
// Supports both PKCS#1 ("RSA PRIVATE KEY") and PKCS#8 ("PRIVATE KEY") PEM blocks.
|
|
|
|
|
func loadPrivateKey(path string) (*rsa.PrivateKey, error) {
|
|
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("read key file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
block, _ := pem.Decode(data)
|
|
|
|
|
if block == nil {
|
|
|
|
|
return nil, fmt.Errorf("no PEM block found in %q", path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch block.Type {
|
|
|
|
|
case "RSA PRIVATE KEY":
|
|
|
|
|
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("parse PKCS1 private key: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return key, nil
|
|
|
|
|
case "PRIVATE KEY":
|
|
|
|
|
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("parse PKCS8 private key: %w", err)
|
|
|
|
|
}
|
|
|
|
|
rsaKey, ok := key.(*rsa.PrivateKey)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("private key is not an RSA key")
|
|
|
|
|
}
|
|
|
|
|
return rsaKey, nil
|
|
|
|
|
default:
|
|
|
|
|
return nil, fmt.Errorf("unexpected PEM block type %q; expected RSA PRIVATE KEY or PRIVATE KEY", block.Type)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// buildClientRegistry converts []ClientConfig into the map used by handlers.
|
2026-07-27 20:03:07 +02:00
|
|
|
func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.Client, error) {
|
2026-03-13 02:18:36 +01:00
|
|
|
m := make(map[string]*domain.Client, len(cfgClients))
|
|
|
|
|
for i := range cfgClients {
|
|
|
|
|
c := &cfgClients[i]
|
2026-07-27 20:03:07 +02:00
|
|
|
clientSecret := ""
|
2026-08-23 13:10:13 +02:00
|
|
|
var clientTokenLifetime time.Duration
|
2026-07-27 20:03:07 +02:00
|
|
|
if strings.HasPrefix(c.SecretRef, "env:") {
|
|
|
|
|
envName := strings.TrimPrefix(c.SecretRef, "env:")
|
|
|
|
|
clientSecret = os.Getenv(envName)
|
|
|
|
|
if clientSecret == "" {
|
|
|
|
|
return nil, fmt.Errorf("client %q secret environment variable %q is empty", c.ClientID, envName)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-23 13:10:13 +02:00
|
|
|
if c.TokenLifetime != "" {
|
|
|
|
|
parsedLifetime, parseErr := time.ParseDuration(c.TokenLifetime)
|
|
|
|
|
if parseErr != nil {
|
|
|
|
|
return nil, fmt.Errorf("client %q tokenLifetime is invalid: %w", c.ClientID, parseErr)
|
|
|
|
|
}
|
|
|
|
|
clientTokenLifetime = parsedLifetime
|
|
|
|
|
}
|
2026-03-13 02:18:36 +01:00
|
|
|
m[c.ClientID] = &domain.Client{
|
2026-08-23 13:10:13 +02:00
|
|
|
ClientID: c.ClientID,
|
|
|
|
|
DisplayName: c.DisplayName,
|
|
|
|
|
RedirectURIs: c.RedirectURIs,
|
|
|
|
|
AllowedScopes: c.AllowedScopes,
|
|
|
|
|
GrantTypes: c.GrantTypes,
|
|
|
|
|
ClientType: c.ClientType,
|
|
|
|
|
SecretRef: c.SecretRef,
|
|
|
|
|
ClientSecret: clientSecret,
|
2026-09-05 00:41:17 +02:00
|
|
|
Audience: c.Audience,
|
2026-08-23 13:10:13 +02:00
|
|
|
ServiceSubject: c.ServiceSubject,
|
|
|
|
|
Tenant: c.Tenant,
|
2026-08-16 01:05:27 +02:00
|
|
|
Roles: c.Roles,
|
2026-08-23 13:10:13 +02:00
|
|
|
TokenLifetime: clientTokenLifetime,
|
2026-08-16 01:05:27 +02:00
|
|
|
MFARequired: c.MFARequired,
|
|
|
|
|
RegistrationURL: c.RegistrationURL,
|
|
|
|
|
EnrollmentURL: c.EnrollmentURL,
|
2026-03-13 02:18:36 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-27 20:03:07 +02:00
|
|
|
return m, nil
|
2026-03-13 02:18:36 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// withEmitter wraps a handler to inject the telemetry emitter into every request context.
|
|
|
|
|
func withEmitter(next http.Handler, e telemetry.Emitter) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
ctx := telemetry.WithEmitter(context.Background(), e)
|
|
|
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
|
|
|
})
|
feat: implement T01-T04 — Go module, canonical model, LDAP validator, error taxonomy
- T01: Go module (keycape), full directory skeleton, Makefile, CI workflow
- T02: spec/canonical-model.yaml with 6 entities + Go domain types
- T03: spec/ldap-schema.yaml + validator binary with structural/semantic rules
- T04: Error taxonomy — 4 stable error types, JSON format, HTTP helpers
28 tests pass, go vet clean, go build clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 01:27:54 +01:00
|
|
|
}
|
2026-09-08 08:59:40 +02:00
|
|
|
|
|
|
|
|
// buildTenantEngine returns the tenant_roles cache client, or nil when the
|
|
|
|
|
// claim is not configured. Validation has already accepted the URL and timeout.
|
|
|
|
|
func buildTenantEngine(cfg config.TenantEngineConfig) *tenantengine.Client {
|
|
|
|
|
if cfg.BaseURL == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
var httpClient *http.Client
|
|
|
|
|
if cfg.Timeout != "" {
|
|
|
|
|
if timeout, err := time.ParseDuration(cfg.Timeout); err == nil {
|
|
|
|
|
httpClient = &http.Client{Timeout: timeout}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return tenantengine.New(cfg.BaseURL, httpClient)
|
|
|
|
|
}
|