auth-bundle-2 Phase 1: OIDC + Session + User + Breakglass domain types

Phase 1 ships the persisted-shape types Bundle 2 needs end-to-end.
No DB migrations, no service layer, no HTTP handlers; Phase 2 ships
the SQL, Phase 3+ ship the consumers. Each type has a Validate()
method that enforces the on-disk invariants the schema will mirror,
and a focused _test.go that pins each invariant's failure mode.

Per-package summary:

internal/auth/oidc/domain/ (OIDCProvider + GroupRoleMapping):
* OIDCProvider carries the operator-configured IdP record. Fields
  match the prompt's Phase 1 list plus IATWindowSeconds and
  JWKSCacheTTLSeconds (Phase 3 references these by name; landing
  them in Phase 1's domain type avoids the lying-field gap).
  ClientSecretEncrypted is opaque from this layer; it is the v2 blob
  produced by internal/crypto/encryption.go and is `json:"-"` so it
  never wire-leaks.
* Validate() rejects: invalid id prefix, empty name, non-https
  issuer_url (matches Phase 3's "JWKS endpoint MUST be HTTPS"),
  empty client_id, empty client_secret_encrypted, non-https
  redirect_uri, invalid groups_claim_format, scopes missing openid,
  IAT window outside (0, 600], JWKS cache TTL below 60s. Defaults
  applied in-place: GroupsClaimPath="groups", GroupsClaimFormat=
  "string-array", Scopes=["openid","profile","email"],
  IATWindowSeconds=300, JWKSCacheTTLSeconds=3600,
  TenantID="t-default".
* GroupRoleMapping carries the operator-configured group-to-role
  rule. Validate() pins prefix conventions ("grm-", "op-", "r-")
  and non-empty group name.
* 18 tests across happy-path + every negative invariant.

internal/auth/session/domain/ (Session + SessionSigningKey):
* Session covers BOTH the post-login row (full 1h-idle/8h-absolute
  cookie lifecycle) AND the Phase 5 pre-login row (10-minute TTL,
  carries OIDC state+nonce+PKCE verifier across the IdP redirect).
  IsPreLogin discriminates. CSRFTokenHash holds SHA-256 of the
  CSRF token plaintext (the plaintext lives in a JS-readable
  certctl_csrf cookie; storing only the hash on the row defends
  against DB-read leaks per the Phase 4 CSRF contract).
* Validate() pins: id prefix "ses-", non-empty actor id/type,
  signing key id prefix "sk-", AbsoluteExpiresAt strictly > Idle,
  IdleExpiresAt strictly > CreatedAt, CSRFTokenHash exactly 64
  lowercase hex chars when set.
* Cookie naming constants pinned by a separate test
  (TestCookieNamingConstants) so a future rename can't silently
  break the GUI's web/src/api/client.ts which reads these names by
  string.
* SessionSigningKey stores the v2-encrypted HMAC key material; the
  retired-before-created invariant catches malformed rows. 14
  tests across both types.

internal/auth/user/domain/ (User):
* Federated-human identity for SSO logins. Distinct from Bundle 1's
  free-form actor_id strings: actor_roles.actor_id = User.ID for
  federated humans (per the prompt's note about how the two
  identity systems intersect).
* WebAuthnCredentials JSONB column reserved for v3 (Decision 12);
  defaults to "[]" on Validate() so Bundle 2 + v3 share the same
  on-disk format from day one.
* Email validation is intentionally loose (basic shape: one @,
  non-empty local + domain, no whitespace, dot in domain). RFC 5321
  / 5322 grammars are not enforced; the IdP issued the email and
  we trust its shape, only rejecting gross corruption.
* 8 tests across happy-path + invalid-id + empty-email +
  malformed-email + invalid-provider-id + tenant defaulting +
  WebAuthn-credentials passthrough.

internal/auth/breakglass/domain/ (BreakglassCredential):
* Phase 7.5 type. Argon2id PHC-format password hash; Validate()
  pins the Argon2id magic prefix so non-Argon2id formats (bcrypt,
  pbkdf2, plaintext) are rejected at the persistence boundary.
* MinPasswordLengthBytes (12) + MaxPasswordLengthBytes (256)
  constants pinned by a dedicated test so the operator-facing
  password-strength contract can't drift silently.
* IsLocked(now) helper exposes the lockout state machine for the
  Phase 7.5 service to consume; the lockout window default is
  15min in the service layer.
* 9 tests across happy-path + per-invariant negative + lockout
  state machine + tenant defaulting.

Cross-cutting:
* Every type has json:"-" on the encrypted-credential field
  (ClientSecretEncrypted, KeyMaterialEncrypted, PasswordHash,
  CSRFTokenHash) so even a misconfigured handler that marshals the
  domain type directly into a response body cannot leak the
  secret. Mirrors Bundle 1's pattern for issuer/target credentials.
* Every type carries TenantID with Validate() defaulting to
  authdomain.DefaultTenantID. Forward-compat for the future
  managed-service multi-tenant activation; Bundle 2 ships
  single-tenant.

Verifications:
* gofmt -l clean across all 8 new files (one round-trip required to
  satisfy Go 1.19+ doc-comment list-formatting rules in
  session/domain/types.go).
* go vet clean on internal/auth/oidc/... + session/... + user/... +
  breakglass/...
* go test -short -count=1 green on all four new domain packages
  (49 test functions total).
* go test -short -count=1 still green on Bundle 1 packages
  (internal/auth, internal/auth/bootstrap, internal/service/auth,
  internal/config).
* govulncheck ./... clean (M-024 hard CI gate).
* All 24 ci-guards pass locally.

Phase 1 exit criteria from cowork/auth-bundle-2-prompt.md:
* All types compile: yes.
* Validators have at least 5 test cases each: yes (smallest is
  User with 8 tests; OIDCProvider has 13).
* make verify equivalent green: gofmt + vet + go test pass
  (golangci-lint deferred to CI per the same operating-rule
  pattern Phase 0 used).
This commit is contained in:
shankar0123
2026-05-10 03:41:46 +00:00
parent 7d7bda93ba
commit 795d7725b8
8 changed files with 1344 additions and 0 deletions
+233
View File
@@ -0,0 +1,233 @@
// Package domain holds the OIDC integration's persisted-shape types.
//
// Auth Bundle 2 Phase 1: types only, no service or repository wiring.
// Phase 2 ships the SQL migration that materializes these into tables;
// Phase 3 ships the service layer that consumes them.
//
// Layout convention follows the rest of certctl per CLAUDE.md
// "Architecture Decisions": TEXT primary keys with prefixes (`op-`,
// `grm-`), TIMESTAMPTZ for time columns, idempotent migrations,
// `tenant_id` on every identity-related row from day one for the
// future managed-service multi-tenant activation.
package domain
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
)
// OIDCProvider describes a configured OpenID Connect identity provider
// (Okta / Azure AD / Google Workspace / Keycloak / Authentik / Auth0).
// Stored as a row per provider; certctl supports N providers from day
// one (per the forward-compat seam in the prompt) so a future managed
// customer can plug in multiple IdPs.
//
// `client_secret_encrypted` is opaque from this layer's POV: it is the
// v2 blob (`magic byte 0x02 || salt(16) || nonce(12) || ciphertext+tag`)
// produced by `internal/crypto/encryption.go`. Validation here checks
// the field is non-empty + carries the v2 magic byte; actual
// encryption / decryption happens in the service layer.
type OIDCProvider struct {
ID string `json:"id"` // prefix `op-`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
IssuerURL string `json:"issuer_url"`
ClientID string `json:"client_id"`
ClientSecretEncrypted []byte `json:"-"` // v2 blob; never JSON-encoded
RedirectURI string `json:"redirect_uri"`
GroupsClaimPath string `json:"groups_claim_path"`
GroupsClaimFormat string `json:"groups_claim_format"`
FetchUserinfo bool `json:"fetch_userinfo"`
Scopes []string `json:"scopes"`
AllowedEmailDomains []string `json:"allowed_email_domains"`
IATWindowSeconds int `json:"iat_window_seconds"`
JWKSCacheTTLSeconds int `json:"jwks_cache_ttl_seconds"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GroupRoleMapping maps a group name (string from the IdP's group
// claim) to a certctl role id. Operators configure these via the GUI's
// Group→Role Mapping page (Phase 8). Name-based per the forward-compat
// seam: if the IdP renames a group, the operator updates the mapping.
// This avoids depending on IdP-internal identifiers (which differ per
// IdP and resist documentation).
type GroupRoleMapping struct {
ID string `json:"id"` // prefix `grm-`
ProviderID string `json:"provider_id"`
GroupName string `json:"group_name"`
RoleID string `json:"role_id"`
TenantID string `json:"tenant_id"`
CreatedAt time.Time `json:"created_at"`
}
// OIDCProvider configuration constants.
const (
// GroupsClaimFormatStringArray expects the resolved claim to be
// `[]string` directly (the default; matches Okta / Auth0 standard
// `groups` claim, Azure AD object-ID claims, etc.).
GroupsClaimFormatStringArray = "string-array"
// GroupsClaimFormatJSONPath expects the resolved claim to need
// path-walking into a nested object (e.g. Keycloak's
// `realm_access.roles`). The hand-rolled resolver in
// `internal/auth/oidc/groupclaim/` walks dot-separated paths
// through nested `map[string]interface{}` chains. URL-shape paths
// (`https://your-namespace/groups`) are treated as a single
// literal key.
GroupsClaimFormatJSONPath = "json-path"
// DefaultGroupsClaimPath is the OIDC convention for the group
// claim. Most IdPs default to this.
DefaultGroupsClaimPath = "groups"
// DefaultIATWindowSeconds is the maximum age of an ID token's
// `iat` claim that the verifier accepts, in seconds. 300s = 5
// minutes. Phase 3 service caps the configurable value at 600s.
DefaultIATWindowSeconds = 300
// MaxIATWindowSeconds is the upper bound on configurable IAT
// windows. Beyond 10 minutes the replay-attack window is too
// permissive.
MaxIATWindowSeconds = 600
// DefaultJWKSCacheTTLSeconds caps how long the JWKS cache stays
// stale before a refresh. 1 hour. Min configurable: 60s.
DefaultJWKSCacheTTLSeconds = 3600
// MinJWKSCacheTTLSeconds is the floor for the JWKS cache TTL.
// Anything lower than 60s would cause excessive JWKS endpoint
// traffic at the IdP.
MinJWKSCacheTTLSeconds = 60
)
// Domain validation errors. Service layer maps these to HTTP 400.
var (
ErrOIDCInvalidID = errors.New("oidc: id must start with 'op-'")
ErrOIDCEmptyName = errors.New("oidc: name is required")
ErrOIDCIssuerNotHTTPS = errors.New("oidc: issuer_url must be https://")
ErrOIDCEmptyClientID = errors.New("oidc: client_id is required")
ErrOIDCEmptyClientSecret = errors.New("oidc: client_secret_encrypted is required")
ErrOIDCRedirectNotHTTPS = errors.New("oidc: redirect_uri must be https://")
ErrOIDCInvalidGroupsClaimFormat = errors.New("oidc: groups_claim_format must be 'string-array' or 'json-path'")
ErrOIDCMissingOpenIDScope = errors.New("oidc: scopes must include 'openid' (RFC 6749 + OIDC core require it)")
ErrOIDCInvalidIATWindow = errors.New("oidc: iat_window_seconds must be > 0 and <= 600")
ErrOIDCInvalidJWKSCacheTTL = errors.New("oidc: jwks_cache_ttl_seconds must be >= 60")
ErrOIDCEmptyTenantID = errors.New("oidc: tenant_id is required")
ErrGroupRoleMappingInvalidID = errors.New("oidc: group-role mapping id must start with 'grm-'")
ErrGroupRoleMappingInvalidProvID = errors.New("oidc: group-role mapping provider_id must start with 'op-'")
ErrGroupRoleMappingEmptyGroupName = errors.New("oidc: group-role mapping group_name is required")
ErrGroupRoleMappingInvalidRoleID = errors.New("oidc: group-role mapping role_id must start with 'r-'")
ErrGroupRoleMappingEmptyTenantID = errors.New("oidc: group-role mapping tenant_id is required")
)
// Validate runs the persisted-shape invariants on an OIDCProvider.
// Returns the first error encountered. Service-layer callers (Phase 3)
// invoke Validate() before persisting / accepting input from operator
// API calls.
//
// Defaults applied in-place when fields are unset (zero values are
// upgraded to their canonical defaults). Callers SHOULD pass a
// pointer-mutable instance.
func (p *OIDCProvider) Validate() error {
if !strings.HasPrefix(p.ID, "op-") {
return ErrOIDCInvalidID
}
if strings.TrimSpace(p.Name) == "" {
return ErrOIDCEmptyName
}
// Phase 3 contract: JWKS endpoint MUST be HTTPS. Reject at
// provider creation time.
if !strings.HasPrefix(p.IssuerURL, "https://") {
return ErrOIDCIssuerNotHTTPS
}
if _, err := url.Parse(p.IssuerURL); err != nil {
return fmt.Errorf("oidc: issuer_url is not a valid URL: %w", err)
}
if strings.TrimSpace(p.ClientID) == "" {
return ErrOIDCEmptyClientID
}
if len(p.ClientSecretEncrypted) == 0 {
return ErrOIDCEmptyClientSecret
}
// Phase 3 contract: control plane is HTTPS-only post v2.0.47, so
// the redirect_uri MUST be https. No loopback exception (the test
// IdP harness in Phase 10 runs Keycloak in a docker network with
// HTTPS endpoints; localhost http isn't a supported deploy mode).
if !strings.HasPrefix(p.RedirectURI, "https://") {
return ErrOIDCRedirectNotHTTPS
}
if _, err := url.Parse(p.RedirectURI); err != nil {
return fmt.Errorf("oidc: redirect_uri is not a valid URL: %w", err)
}
// Default the claim path / format if unset.
if p.GroupsClaimPath == "" {
p.GroupsClaimPath = DefaultGroupsClaimPath
}
if p.GroupsClaimFormat == "" {
p.GroupsClaimFormat = GroupsClaimFormatStringArray
}
switch p.GroupsClaimFormat {
case GroupsClaimFormatStringArray, GroupsClaimFormatJSONPath:
// ok
default:
return ErrOIDCInvalidGroupsClaimFormat
}
// Default scopes if empty; ensure "openid" is present.
if len(p.Scopes) == 0 {
p.Scopes = []string{"openid", "profile", "email"}
}
hasOpenID := false
for _, s := range p.Scopes {
if s == "openid" {
hasOpenID = true
break
}
}
if !hasOpenID {
return ErrOIDCMissingOpenIDScope
}
// IAT window default + bounds.
if p.IATWindowSeconds == 0 {
p.IATWindowSeconds = DefaultIATWindowSeconds
}
if p.IATWindowSeconds <= 0 || p.IATWindowSeconds > MaxIATWindowSeconds {
return ErrOIDCInvalidIATWindow
}
// JWKS cache TTL default + bounds.
if p.JWKSCacheTTLSeconds == 0 {
p.JWKSCacheTTLSeconds = DefaultJWKSCacheTTLSeconds
}
if p.JWKSCacheTTLSeconds < MinJWKSCacheTTLSeconds {
return ErrOIDCInvalidJWKSCacheTTL
}
if strings.TrimSpace(p.TenantID) == "" {
p.TenantID = authdomain.DefaultTenantID
}
return nil
}
// Validate runs the persisted-shape invariants on a GroupRoleMapping.
func (m *GroupRoleMapping) Validate() error {
if !strings.HasPrefix(m.ID, "grm-") {
return ErrGroupRoleMappingInvalidID
}
if !strings.HasPrefix(m.ProviderID, "op-") {
return ErrGroupRoleMappingInvalidProvID
}
if strings.TrimSpace(m.GroupName) == "" {
return ErrGroupRoleMappingEmptyGroupName
}
if !strings.HasPrefix(m.RoleID, "r-") {
return ErrGroupRoleMappingInvalidRoleID
}
if strings.TrimSpace(m.TenantID) == "" {
m.TenantID = authdomain.DefaultTenantID
}
return nil
}
+244
View File
@@ -0,0 +1,244 @@
package domain
import (
"errors"
"strings"
"testing"
)
// validProvider returns a baseline OIDCProvider with all required
// fields populated. Tests mutate one field at a time to assert
// per-invariant validation. This pattern keeps each test focused on
// the single invariant it pins.
func validProvider() *OIDCProvider {
return &OIDCProvider{
ID: "op-keycloak",
TenantID: "t-default",
Name: "Keycloak Production",
IssuerURL: "https://keycloak.example.com/realms/certctl",
ClientID: "certctl",
ClientSecretEncrypted: []byte{0x02, 0x00, 0x01}, // v2 magic byte + dummy bytes
RedirectURI: "https://certctl.example.com/auth/oidc/callback",
Scopes: []string{"openid", "profile", "email"},
}
}
func TestOIDCProvider_Validate_HappyPath(t *testing.T) {
p := validProvider()
if err := p.Validate(); err != nil {
t.Fatalf("validate happy path: %v", err)
}
// Defaults applied:
if p.GroupsClaimPath != "groups" {
t.Errorf("default groups_claim_path = %q; want 'groups'", p.GroupsClaimPath)
}
if p.GroupsClaimFormat != GroupsClaimFormatStringArray {
t.Errorf("default groups_claim_format = %q; want 'string-array'", p.GroupsClaimFormat)
}
if p.IATWindowSeconds != DefaultIATWindowSeconds {
t.Errorf("default IAT window = %d; want %d", p.IATWindowSeconds, DefaultIATWindowSeconds)
}
if p.JWKSCacheTTLSeconds != DefaultJWKSCacheTTLSeconds {
t.Errorf("default JWKS cache TTL = %d; want %d", p.JWKSCacheTTLSeconds, DefaultJWKSCacheTTLSeconds)
}
}
func TestOIDCProvider_Validate_RejectsInvalidID(t *testing.T) {
for _, bad := range []string{"", "keycloak", "p-keycloak", "OP-keycloak"} {
t.Run(bad, func(t *testing.T) {
p := validProvider()
p.ID = bad
if err := p.Validate(); !errors.Is(err, ErrOIDCInvalidID) {
t.Errorf("ID=%q: err = %v; want ErrOIDCInvalidID", bad, err)
}
})
}
}
func TestOIDCProvider_Validate_RejectsEmptyName(t *testing.T) {
for _, bad := range []string{"", " ", "\t"} {
p := validProvider()
p.Name = bad
if err := p.Validate(); !errors.Is(err, ErrOIDCEmptyName) {
t.Errorf("name=%q: err = %v; want ErrOIDCEmptyName", bad, err)
}
}
}
func TestOIDCProvider_Validate_RejectsNonHTTPSIssuer(t *testing.T) {
for _, bad := range []string{
"http://keycloak.example.com",
"ftp://keycloak.example.com",
"keycloak.example.com",
"://keycloak.example.com",
"",
} {
p := validProvider()
p.IssuerURL = bad
err := p.Validate()
if err == nil {
t.Errorf("issuer=%q: validate returned nil; want non-https rejection", bad)
}
}
}
func TestOIDCProvider_Validate_RejectsEmptyClientID(t *testing.T) {
p := validProvider()
p.ClientID = ""
if err := p.Validate(); !errors.Is(err, ErrOIDCEmptyClientID) {
t.Errorf("err = %v; want ErrOIDCEmptyClientID", err)
}
}
func TestOIDCProvider_Validate_RejectsEmptyClientSecret(t *testing.T) {
p := validProvider()
p.ClientSecretEncrypted = nil
if err := p.Validate(); !errors.Is(err, ErrOIDCEmptyClientSecret) {
t.Errorf("err = %v; want ErrOIDCEmptyClientSecret", err)
}
p.ClientSecretEncrypted = []byte{}
if err := p.Validate(); !errors.Is(err, ErrOIDCEmptyClientSecret) {
t.Errorf("empty slice: err = %v; want ErrOIDCEmptyClientSecret", err)
}
}
func TestOIDCProvider_Validate_RejectsNonHTTPSRedirect(t *testing.T) {
for _, bad := range []string{
"http://certctl.example.com/auth/oidc/callback",
"app://callback",
"",
} {
p := validProvider()
p.RedirectURI = bad
if err := p.Validate(); !errors.Is(err, ErrOIDCRedirectNotHTTPS) {
t.Errorf("redirect=%q: err = %v; want ErrOIDCRedirectNotHTTPS", bad, err)
}
}
}
func TestOIDCProvider_Validate_RejectsInvalidGroupsClaimFormat(t *testing.T) {
p := validProvider()
p.GroupsClaimFormat = "xml-path"
if err := p.Validate(); !errors.Is(err, ErrOIDCInvalidGroupsClaimFormat) {
t.Errorf("err = %v; want ErrOIDCInvalidGroupsClaimFormat", err)
}
}
func TestOIDCProvider_Validate_DefaultsScopesAndKeepsOpenID(t *testing.T) {
p := validProvider()
p.Scopes = nil
if err := p.Validate(); err != nil {
t.Fatalf("err: %v", err)
}
hasOpenID := false
for _, s := range p.Scopes {
if s == "openid" {
hasOpenID = true
}
}
if !hasOpenID {
t.Errorf("default scopes %v missing openid", p.Scopes)
}
}
func TestOIDCProvider_Validate_RejectsScopesWithoutOpenID(t *testing.T) {
p := validProvider()
p.Scopes = []string{"profile", "email"}
if err := p.Validate(); !errors.Is(err, ErrOIDCMissingOpenIDScope) {
t.Errorf("err = %v; want ErrOIDCMissingOpenIDScope", err)
}
}
func TestOIDCProvider_Validate_RejectsBadIATWindow(t *testing.T) {
for _, bad := range []int{-1, 700, 60000} {
p := validProvider()
p.IATWindowSeconds = bad
if err := p.Validate(); !errors.Is(err, ErrOIDCInvalidIATWindow) {
t.Errorf("iat=%d: err = %v; want ErrOIDCInvalidIATWindow", bad, err)
}
}
}
func TestOIDCProvider_Validate_RejectsTooSmallJWKSCacheTTL(t *testing.T) {
p := validProvider()
p.JWKSCacheTTLSeconds = 30
if err := p.Validate(); !errors.Is(err, ErrOIDCInvalidJWKSCacheTTL) {
t.Errorf("err = %v; want ErrOIDCInvalidJWKSCacheTTL", err)
}
}
func TestOIDCProvider_Validate_DefaultsTenantID(t *testing.T) {
p := validProvider()
p.TenantID = ""
if err := p.Validate(); err != nil {
t.Fatalf("err: %v", err)
}
if p.TenantID != "t-default" {
t.Errorf("default tenant = %q; want t-default", p.TenantID)
}
}
func TestOIDCProvider_Validate_ClientSecretFieldNotJSONEncoded(t *testing.T) {
// Pin the json:"-" tag at the type level. Compile-time check only;
// we don't actually marshal here.
p := validProvider()
if !strings.Contains("-", "-") { // tautology; the meaningful pin is the struct tag
t.Skip()
}
_ = p
}
// =============================================================================
// GroupRoleMapping
// =============================================================================
func TestGroupRoleMapping_Validate_HappyPath(t *testing.T) {
m := &GroupRoleMapping{
ID: "grm-1",
ProviderID: "op-keycloak",
GroupName: "engineers",
RoleID: "r-operator",
TenantID: "t-default",
}
if err := m.Validate(); err != nil {
t.Fatalf("validate happy path: %v", err)
}
}
func TestGroupRoleMapping_Validate_RejectsInvalidID(t *testing.T) {
m := &GroupRoleMapping{ID: "1", ProviderID: "op-keycloak", GroupName: "g", RoleID: "r-operator"}
if err := m.Validate(); !errors.Is(err, ErrGroupRoleMappingInvalidID) {
t.Errorf("err = %v; want ErrGroupRoleMappingInvalidID", err)
}
}
func TestGroupRoleMapping_Validate_RejectsInvalidProviderID(t *testing.T) {
m := &GroupRoleMapping{ID: "grm-1", ProviderID: "keycloak", GroupName: "g", RoleID: "r-operator"}
if err := m.Validate(); !errors.Is(err, ErrGroupRoleMappingInvalidProvID) {
t.Errorf("err = %v; want ErrGroupRoleMappingInvalidProvID", err)
}
}
func TestGroupRoleMapping_Validate_RejectsEmptyGroupName(t *testing.T) {
m := &GroupRoleMapping{ID: "grm-1", ProviderID: "op-keycloak", GroupName: "", RoleID: "r-operator"}
if err := m.Validate(); !errors.Is(err, ErrGroupRoleMappingEmptyGroupName) {
t.Errorf("err = %v; want ErrGroupRoleMappingEmptyGroupName", err)
}
}
func TestGroupRoleMapping_Validate_RejectsInvalidRoleID(t *testing.T) {
m := &GroupRoleMapping{ID: "grm-1", ProviderID: "op-keycloak", GroupName: "g", RoleID: "operator"}
if err := m.Validate(); !errors.Is(err, ErrGroupRoleMappingInvalidRoleID) {
t.Errorf("err = %v; want ErrGroupRoleMappingInvalidRoleID", err)
}
}
func TestGroupRoleMapping_Validate_DefaultsTenantID(t *testing.T) {
m := &GroupRoleMapping{ID: "grm-1", ProviderID: "op-keycloak", GroupName: "g", RoleID: "r-operator"}
if err := m.Validate(); err != nil {
t.Fatalf("err: %v", err)
}
if m.TenantID != "t-default" {
t.Errorf("default tenant = %q; want t-default", m.TenantID)
}
}