From f5f3034cd8c8d8fb72d26b16f65213fb53b78db6 Mon Sep 17 00:00:00 2001 From: Jan Baraniewski Date: Fri, 19 Jun 2026 16:53:05 +0200 Subject: [PATCH 1/4] feat(db): add issuer model, dao and migration for dynamic oidc registration Add the persistent Issuer entity backing dynamic OIDC issuer registration: an issuer-to-org trust binding with full claim-mapping parity to the static config (claimMappings, audiences, scopes, jwksTimeout, allowDuplicateStaticOrgNames). IssuerSQLDAO provides Create/GetByID/GetByIssuerURL/GetAll/Update/Delete with soft delete. A partial unique index on issuer_url (WHERE deleted IS NULL) enforces one active binding per issuer URL while allowing re-registration after an issuer is offboarded. Signed-off-by: Jan Baraniewski --- rest-api/db/pkg/db/model/issuer.go | 312 ++++++++++++++++++ rest-api/db/pkg/db/model/issuer_test.go | 179 ++++++++++ .../pkg/migrations/20260617120000_issuer.go | 43 +++ 3 files changed, 534 insertions(+) create mode 100644 rest-api/db/pkg/db/model/issuer.go create mode 100644 rest-api/db/pkg/db/model/issuer_test.go create mode 100644 rest-api/db/pkg/migrations/20260617120000_issuer.go diff --git a/rest-api/db/pkg/db/model/issuer.go b/rest-api/db/pkg/db/model/issuer.go new file mode 100644 index 0000000000..feab12fe1e --- /dev/null +++ b/rest-api/db/pkg/db/model/issuer.go @@ -0,0 +1,312 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "context" + "database/sql" + "time" + + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + stracer "github.com/NVIDIA/infra-controller/rest-api/db/pkg/tracer" + "github.com/google/uuid" + + "github.com/uptrace/bun" +) + +const ( + // IssuerStatusPending indicates the issuer's JWKS has not yet been fetched successfully + IssuerStatusPending = "Pending" + // IssuerStatusReady indicates the issuer's JWKS has been fetched successfully + IssuerStatusReady = "Ready" +) + +// IssuerClaimMapping is the persisted form of an issuer claim mapping. +type IssuerClaimMapping struct { + OrgAttribute string `json:"orgAttribute,omitempty"` + OrgDisplayAttribute string `json:"orgDisplayAttribute,omitempty"` + OrgName string `json:"orgName,omitempty"` + OrgDisplayName string `json:"orgDisplayName,omitempty"` + RolesAttribute string `json:"rolesAttribute,omitempty"` + Roles []string `json:"roles,omitempty"` + IsServiceAccount bool `json:"isServiceAccount,omitempty"` +} + +// Issuer represents entries in the issuer table +type Issuer struct { + bun.BaseModel `bun:"table:issuer,alias:iss"` + + ID uuid.UUID `bun:"type:uuid,pk"` + Name string `bun:"name,notnull"` + IssuerURL string `bun:"issuer_url,notnull"` + JWKSURL string `bun:"jwks_url,notnull"` + Origin string `bun:"origin,notnull"` + ServiceAccount bool `bun:"service_account,notnull"` + Audiences []string `bun:"audiences,type:jsonb"` + Scopes []string `bun:"scopes,type:jsonb"` + JWKSTimeout string `bun:"jwks_timeout"` + ClaimMappings []IssuerClaimMapping `bun:"claim_mappings,type:jsonb"` + AllowDuplicateStaticOrgNames bool `bun:"allow_duplicate_static_org_names,notnull"` + Status string `bun:"status,notnull"` + Created time.Time `bun:"created,nullzero,notnull,default:current_timestamp"` + Updated time.Time `bun:"updated,nullzero,notnull,default:current_timestamp"` + Deleted *time.Time `bun:"deleted,soft_delete"` + CreatedBy uuid.UUID `bun:"created_by,type:uuid,notnull"` +} + +// IssuerCreateInput input parameters for Create method +type IssuerCreateInput struct { + Name string + IssuerURL string + JWKSURL string + Origin string + ServiceAccount bool + Audiences []string + Scopes []string + JWKSTimeout string + ClaimMappings []IssuerClaimMapping + AllowDuplicateStaticOrgNames bool + Status string + CreatedBy uuid.UUID +} + +// IssuerUpdateInput input parameters for Update method. IssuerURL and Origin are immutable. +type IssuerUpdateInput struct { + IssuerID uuid.UUID + Name *string + JWKSURL *string + ServiceAccount *bool + Audiences []string + Scopes []string + JWKSTimeout *string + ClaimMappings []IssuerClaimMapping + AllowDuplicateStaticOrgNames *bool + Status *string +} + +var _ bun.BeforeAppendModelHook = (*Issuer)(nil) + +// BeforeAppendModel is a hook that is called before the model is appended to the query +func (iss *Issuer) BeforeAppendModel(_ context.Context, query bun.Query) error { + switch query.(type) { + case *bun.InsertQuery: + iss.Created = db.GetCurTime() + iss.Updated = db.GetCurTime() + case *bun.UpdateQuery: + iss.Updated = db.GetCurTime() + } + return nil +} + +// IssuerDAO is the data access interface for Issuer +type IssuerDAO interface { + Create(ctx context.Context, tx *db.Tx, input IssuerCreateInput) (*Issuer, error) + GetByID(ctx context.Context, tx *db.Tx, id uuid.UUID) (*Issuer, error) + GetByIssuerURL(ctx context.Context, tx *db.Tx, issuerURL string) (*Issuer, error) + GetAll(ctx context.Context, tx *db.Tx) ([]Issuer, error) + Update(ctx context.Context, tx *db.Tx, input IssuerUpdateInput) (*Issuer, error) + Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error +} + +// IssuerSQLDAO implements IssuerDAO for SQL +type IssuerSQLDAO struct { + dbSession *db.Session + tracerSpan *stracer.TracerSpan +} + +// Create creates a new Issuer from the given input +func (isd IssuerSQLDAO) Create(ctx context.Context, tx *db.Tx, input IssuerCreateInput) (*Issuer, error) { + ctx, issDAOSpan := isd.tracerSpan.CreateChildInCurrentContext(ctx, "IssuerDAO.Create") + if issDAOSpan != nil { + defer issDAOSpan.End() + + isd.tracerSpan.SetAttribute(issDAOSpan, "name", input.Name) + } + + iss := &Issuer{ + ID: uuid.New(), + Name: input.Name, + IssuerURL: input.IssuerURL, + JWKSURL: input.JWKSURL, + Origin: input.Origin, + ServiceAccount: input.ServiceAccount, + Audiences: input.Audiences, + Scopes: input.Scopes, + JWKSTimeout: input.JWKSTimeout, + ClaimMappings: input.ClaimMappings, + AllowDuplicateStaticOrgNames: input.AllowDuplicateStaticOrgNames, + Status: input.Status, + CreatedBy: input.CreatedBy, + } + + _, err := db.GetIDB(tx, isd.dbSession).NewInsert().Model(iss).Exec(ctx) + if err != nil { + return nil, err + } + + niss, err := isd.GetByID(ctx, tx, iss.ID) + if err != nil { + return nil, err + } + + return niss, nil +} + +// GetByID returns an Issuer by ID +// returns db.ErrDoesNotExist error if the record is not found +func (isd IssuerSQLDAO) GetByID(ctx context.Context, tx *db.Tx, id uuid.UUID) (*Issuer, error) { + ctx, issDAOSpan := isd.tracerSpan.CreateChildInCurrentContext(ctx, "IssuerDAO.GetByID") + if issDAOSpan != nil { + defer issDAOSpan.End() + + isd.tracerSpan.SetAttribute(issDAOSpan, "id", id.String()) + } + + iss := &Issuer{} + + err := db.GetIDB(tx, isd.dbSession).NewSelect().Model(iss).Where("iss.id = ?", id).Scan(ctx) + if err != nil { + if err == sql.ErrNoRows { + return nil, db.ErrDoesNotExist + } + return nil, err + } + + return iss, nil +} + +// GetByIssuerURL returns an active Issuer by its issuer URL +// returns db.ErrDoesNotExist error if the record is not found +func (isd IssuerSQLDAO) GetByIssuerURL(ctx context.Context, tx *db.Tx, issuerURL string) (*Issuer, error) { + ctx, issDAOSpan := isd.tracerSpan.CreateChildInCurrentContext(ctx, "IssuerDAO.GetByIssuerURL") + if issDAOSpan != nil { + defer issDAOSpan.End() + + isd.tracerSpan.SetAttribute(issDAOSpan, "issuer_url", issuerURL) + } + + iss := &Issuer{} + + err := db.GetIDB(tx, isd.dbSession).NewSelect().Model(iss).Where("iss.issuer_url = ?", issuerURL).Scan(ctx) + if err != nil { + if err == sql.ErrNoRows { + return nil, db.ErrDoesNotExist + } + return nil, err + } + + return iss, nil +} + +// GetAll returns all active Issuers +func (isd IssuerSQLDAO) GetAll(ctx context.Context, tx *db.Tx) ([]Issuer, error) { + ctx, issDAOSpan := isd.tracerSpan.CreateChildInCurrentContext(ctx, "IssuerDAO.GetAll") + if issDAOSpan != nil { + defer issDAOSpan.End() + } + + isss := []Issuer{} + + err := db.GetIDB(tx, isd.dbSession).NewSelect().Model(&isss).Order("iss.created ASC").Scan(ctx) + if err != nil { + return nil, err + } + + return isss, nil +} + +// Update updates specified fields of an existing Issuer +func (isd IssuerSQLDAO) Update(ctx context.Context, tx *db.Tx, input IssuerUpdateInput) (*Issuer, error) { + ctx, issDAOSpan := isd.tracerSpan.CreateChildInCurrentContext(ctx, "IssuerDAO.Update") + if issDAOSpan != nil { + defer issDAOSpan.End() + + isd.tracerSpan.SetAttribute(issDAOSpan, "id", input.IssuerID.String()) + } + + iss := &Issuer{ + ID: input.IssuerID, + } + + updatedFields := []string{} + + if input.Name != nil { + iss.Name = *input.Name + updatedFields = append(updatedFields, "name") + } + if input.JWKSURL != nil { + iss.JWKSURL = *input.JWKSURL + updatedFields = append(updatedFields, "jwks_url") + } + if input.ServiceAccount != nil { + iss.ServiceAccount = *input.ServiceAccount + updatedFields = append(updatedFields, "service_account") + } + if input.Audiences != nil { + iss.Audiences = input.Audiences + updatedFields = append(updatedFields, "audiences") + } + if input.Scopes != nil { + iss.Scopes = input.Scopes + updatedFields = append(updatedFields, "scopes") + } + if input.JWKSTimeout != nil { + iss.JWKSTimeout = *input.JWKSTimeout + updatedFields = append(updatedFields, "jwks_timeout") + } + if input.ClaimMappings != nil { + iss.ClaimMappings = input.ClaimMappings + updatedFields = append(updatedFields, "claim_mappings") + } + if input.AllowDuplicateStaticOrgNames != nil { + iss.AllowDuplicateStaticOrgNames = *input.AllowDuplicateStaticOrgNames + updatedFields = append(updatedFields, "allow_duplicate_static_org_names") + } + if input.Status != nil { + iss.Status = *input.Status + updatedFields = append(updatedFields, "status") + } + + if len(updatedFields) > 0 { + updatedFields = append(updatedFields, "updated") + + _, err := db.GetIDB(tx, isd.dbSession).NewUpdate().Model(iss).Column(updatedFields...).Where("id = ?", input.IssuerID).Exec(ctx) + if err != nil { + return nil, err + } + } + + niss, err := isd.GetByID(ctx, tx, input.IssuerID) + if err != nil { + return nil, err + } + + return niss, nil +} + +// Delete soft-deletes an Issuer by ID +// error is returned only if there is a db error; deleting a non-existent row is a no-op +func (isd IssuerSQLDAO) Delete(ctx context.Context, tx *db.Tx, id uuid.UUID) error { + ctx, issDAOSpan := isd.tracerSpan.CreateChildInCurrentContext(ctx, "IssuerDAO.Delete") + if issDAOSpan != nil { + defer issDAOSpan.End() + + isd.tracerSpan.SetAttribute(issDAOSpan, "id", id.String()) + } + + _, err := db.GetIDB(tx, isd.dbSession).NewDelete().Model((*Issuer)(nil)).Where("id = ?", id).Exec(ctx) + if err != nil { + return err + } + + return nil +} + +// NewIssuerDAO creates and returns a new data access object for Issuer +func NewIssuerDAO(dbSession *db.Session) IssuerDAO { + return IssuerSQLDAO{ + dbSession: dbSession, + tracerSpan: stracer.NewTracerSpan(), + } +} diff --git a/rest-api/db/pkg/db/model/issuer_test.go b/rest-api/db/pkg/db/model/issuer_test.go new file mode 100644 index 0000000000..bfae958a58 --- /dev/null +++ b/rest-api/db/pkg/db/model/issuer_test.go @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "context" + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/util" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testIssuerSetupSchema(t *testing.T, dbSession *db.Session) { + err := dbSession.DB.ResetModel(context.Background(), (*Issuer)(nil)) + assert.Nil(t, err) + // the partial unique index is created by migration, not by ResetModel + _, err = dbSession.DB.ExecContext(context.Background(), + "CREATE UNIQUE INDEX IF NOT EXISTS issuer_issuer_url_active_idx ON issuer(issuer_url) WHERE deleted IS NULL") + assert.Nil(t, err) +} + +func testIssuerCreateInput(name, org, issuerURL string, createdBy uuid.UUID) IssuerCreateInput { + return IssuerCreateInput{ + Name: name, + IssuerURL: issuerURL, + JWKSURL: issuerURL + "/.well-known/jwks.json", + Origin: "custom", + Audiences: []string{"nico"}, + JWKSTimeout: "5s", + ClaimMappings: []IssuerClaimMapping{{ + OrgName: org, + Roles: []string{"TENANT_ADMIN"}, + }}, + Status: IssuerStatusPending, + CreatedBy: createdBy, + } +} + +func TestIssuerSQLDAO_CreateAndGet(t *testing.T) { + ctx := context.Background() + dbSession := util.GetTestDBSession(t, false) + defer dbSession.Close() + testIssuerSetupSchema(t, dbSession) + + dao := NewIssuerDAO(dbSession) + createdBy := uuid.New() + + created, err := dao.Create(ctx, nil, testIssuerCreateInput("acme-idp", "tenant-acme", "https://idp.acme.com", createdBy)) + require.Nil(t, err) + require.NotNil(t, created) + assert.Equal(t, "https://idp.acme.com", created.IssuerURL) + assert.Equal(t, IssuerStatusPending, created.Status) + require.Len(t, created.ClaimMappings, 1) + assert.Equal(t, "tenant-acme", created.ClaimMappings[0].OrgName) + assert.Equal(t, []string{"TENANT_ADMIN"}, created.ClaimMappings[0].Roles) + assert.Equal(t, []string{"nico"}, created.Audiences) + assert.Equal(t, "5s", created.JWKSTimeout) + assert.False(t, created.Created.IsZero()) + + // GetByID + got, err := dao.GetByID(ctx, nil, created.ID) + require.Nil(t, err) + assert.Equal(t, created.ID, got.ID) + assert.Equal(t, "acme-idp", got.Name) + + // GetByIssuerURL + byURL, err := dao.GetByIssuerURL(ctx, nil, "https://idp.acme.com") + require.Nil(t, err) + assert.Equal(t, created.ID, byURL.ID) + + // missing ID / URL -> ErrDoesNotExist + _, err = dao.GetByID(ctx, nil, uuid.New()) + assert.ErrorIs(t, err, db.ErrDoesNotExist) + _, err = dao.GetByIssuerURL(ctx, nil, "https://nope.example.com") + assert.ErrorIs(t, err, db.ErrDoesNotExist) +} + +func TestIssuerSQLDAO_GetAll(t *testing.T) { + ctx := context.Background() + dbSession := util.GetTestDBSession(t, false) + defer dbSession.Close() + testIssuerSetupSchema(t, dbSession) + + dao := NewIssuerDAO(dbSession) + createdBy := uuid.New() + + _, err := dao.Create(ctx, nil, testIssuerCreateInput("acme-idp", "tenant-acme", "https://idp.acme.com", createdBy)) + require.Nil(t, err) + _, err = dao.Create(ctx, nil, testIssuerCreateInput("globex-idp", "tenant-globex", "https://idp.globex.com", createdBy)) + require.Nil(t, err) + _, err = dao.Create(ctx, nil, testIssuerCreateInput("acme-backup", "tenant-acme-backup", "https://idp.acme-backup.com", createdBy)) + require.Nil(t, err) + + all, err := dao.GetAll(ctx, nil) + require.Nil(t, err) + assert.Len(t, all, 3) +} + +func TestIssuerSQLDAO_Update(t *testing.T) { + ctx := context.Background() + dbSession := util.GetTestDBSession(t, false) + defer dbSession.Close() + testIssuerSetupSchema(t, dbSession) + + dao := NewIssuerDAO(dbSession) + created, err := dao.Create(ctx, nil, testIssuerCreateInput("acme-idp", "tenant-acme", "https://idp.acme.com", uuid.New())) + require.Nil(t, err) + + newJWKS := "https://idp.acme.com/rotated/jwks.json" + ready := IssuerStatusReady + updated, err := dao.Update(ctx, nil, IssuerUpdateInput{ + IssuerID: created.ID, + JWKSURL: &newJWKS, + ClaimMappings: []IssuerClaimMapping{{ + OrgName: "tenant-acme", + Roles: []string{"TENANT_ADMIN", "PROVIDER_ADMIN"}, + }}, + Status: &ready, + }) + require.Nil(t, err) + assert.Equal(t, newJWKS, updated.JWKSURL) + require.Len(t, updated.ClaimMappings, 1) + assert.Equal(t, []string{"TENANT_ADMIN", "PROVIDER_ADMIN"}, updated.ClaimMappings[0].Roles) + assert.Equal(t, IssuerStatusReady, updated.Status) + // immutable field unchanged + assert.Equal(t, "https://idp.acme.com", updated.IssuerURL) +} + +func TestIssuerSQLDAO_DuplicateActiveURLRejected(t *testing.T) { + ctx := context.Background() + dbSession := util.GetTestDBSession(t, false) + defer dbSession.Close() + testIssuerSetupSchema(t, dbSession) + + dao := NewIssuerDAO(dbSession) + _, err := dao.Create(ctx, nil, testIssuerCreateInput("acme-idp", "tenant-acme", "https://idp.acme.com", uuid.New())) + require.Nil(t, err) + + // a second active row with the same issuer_url violates the partial unique index + _, err = dao.Create(ctx, nil, testIssuerCreateInput("acme-dup", "tenant-dup", "https://idp.acme.com", uuid.New())) + require.Error(t, err) + assert.True(t, dbSession.GetErrorChecker().IsUniqueConstraintError(err)) +} + +func TestIssuerSQLDAO_SoftDeleteAndReRegister(t *testing.T) { + ctx := context.Background() + dbSession := util.GetTestDBSession(t, false) + defer dbSession.Close() + testIssuerSetupSchema(t, dbSession) + + dao := NewIssuerDAO(dbSession) + created, err := dao.Create(ctx, nil, testIssuerCreateInput("acme-idp", "tenant-acme", "https://idp.acme.com", uuid.New())) + require.Nil(t, err) + + // soft-delete + err = dao.Delete(ctx, nil, created.ID) + require.Nil(t, err) + + // deleted row is excluded from active reads + _, err = dao.GetByID(ctx, nil, created.ID) + assert.ErrorIs(t, err, db.ErrDoesNotExist) + _, err = dao.GetByIssuerURL(ctx, nil, "https://idp.acme.com") + assert.ErrorIs(t, err, db.ErrDoesNotExist) + all, err := dao.GetAll(ctx, nil) + require.Nil(t, err) + assert.Len(t, all, 0) + + // the same issuer URL can be re-registered after offboard + recreated, err := dao.Create(ctx, nil, testIssuerCreateInput("acme-idp", "tenant-acme", "https://idp.acme.com", uuid.New())) + require.Nil(t, err) + assert.NotEqual(t, created.ID, recreated.ID) + active, err := dao.GetByIssuerURL(ctx, nil, "https://idp.acme.com") + require.Nil(t, err) + assert.Equal(t, recreated.ID, active.ID) +} diff --git a/rest-api/db/pkg/migrations/20260617120000_issuer.go b/rest-api/db/pkg/migrations/20260617120000_issuer.go new file mode 100644 index 0000000000..c94dc8c516 --- /dev/null +++ b/rest-api/db/pkg/migrations/20260617120000_issuer.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + tx, terr := db.BeginTx(ctx, &sql.TxOptions{}) + if terr != nil { + handlePanic(terr, "failed to begin transaction") + } + + // create issuer table + _, err := tx.NewCreateTable().Model((*model.Issuer)(nil)).IfNotExists().Exec(ctx) + handleError(tx, err) + + // unique issuer_url across active rows + _, err = tx.Exec("DROP INDEX IF EXISTS issuer_issuer_url_active_idx") + handleError(tx, err) + _, err = tx.Exec("CREATE UNIQUE INDEX issuer_issuer_url_active_idx ON issuer(issuer_url) WHERE deleted IS NULL") + handleError(tx, err) + + terr = tx.Commit() + if terr != nil { + handlePanic(terr, "failed to commit transaction") + } + + fmt.Print(" [up migration] Created 'issuer' table successfully. ") + return nil + }, func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [down migration] No action taken") + return nil + }) +} From cdd01ea8c2c2cf94ce6ee9bc637652d8d2e6bf0e Mon Sep 17 00:00:00 2001 From: Jan Baraniewski Date: Fri, 19 Jun 2026 16:53:11 +0200 Subject: [PATCH 2/4] feat(auth): seed and hot-apply db issuers into the jwt origin map Wire DB-registered issuers into the live JWT origin map so they verify identically to statically-configured issuers. - jwksConfigForIssuer builds a JwksConfig from a DB issuer; ApplyIssuer fetches the JWKS first and installs the config only on success, so a fetch failure never clobbers an existing live config. - ValidateRegisteredIssuer validates a candidate against the static config and the registered DB issuers using the existing issuer-config rules. - SeedIssuersFromDB installs all registered issuers at startup (best-effort JWKS, skipping any URL that is statically configured). - reservedOrgNames becomes a thread-safe set wired by reference into every JwksConfig at insertion time (consulted only for dynamic claim mappings) and recomputed as issuers are registered or removed. Signed-off-by: Jan Baraniewski --- rest-api/api/internal/config/config.go | 31 +-- rest-api/api/internal/config/config_test.go | 87 ++++++++ rest-api/api/internal/config/issuer.go | 219 ++++++++++++++++++++ rest-api/api/internal/server/server.go | 9 + rest-api/auth/pkg/config/jwks.go | 47 ++++- rest-api/auth/pkg/config/jwks_test.go | 24 +++ rest-api/auth/pkg/config/jwtOrigin.go | 20 +- rest-api/auth/pkg/core/jwks.go | 13 +- rest-api/auth/pkg/core/jwks_test.go | 19 ++ 9 files changed, 437 insertions(+), 32 deletions(-) create mode 100644 rest-api/api/internal/config/issuer.go diff --git a/rest-api/api/internal/config/config.go b/rest-api/api/internal/config/config.go index 9db13b77e2..0557b0eddb 100644 --- a/rest-api/api/internal/config/config.go +++ b/rest-api/api/internal/config/config.go @@ -430,32 +430,22 @@ func (c *Config) GetOrInitJWTOriginConfig() *cauth.JWTOriginConfig { log.Panic().Err(err).Msg("Invalid issuers configuration") } - // First pass: collect all static org names (lowercased) from all issuers - reservedOrgNames := make(map[string]bool) - for _, issuerCfg := range issuersConfig { - for _, mapping := range issuerCfg.ClaimMappings { - if mapping.OrgName != "" { - reservedOrgNames[strings.ToLower(mapping.OrgName)] = true - } - } - } + // First pass: reserve the static org names declared in the config file. + c.JwtOriginConfig.ReplaceReservedOrgs(c.configStaticOrgNames()) - // Second pass: create jwksConfigs and assign reservedOrgNames only to those with dynamic mappings + // Second pass: create jwksConfigs. AddJwksConfig wires the shared + // reserved-org set into each config (only consulted for dynamic mappings). for _, issuerCfg := range issuersConfig { origin, _ := issuerCfg.GetOrigin() // Already validated jwksTimeout, _ := issuerCfg.GetJWKSTimeout() - // Normalize org names in claim mappings and check for dynamic mappings + // Normalize org names in claim mappings normalizedMappings := make([]cauth.ClaimMapping, len(issuerCfg.ClaimMappings)) - hasDynamicMapping := false for i, mapping := range issuerCfg.ClaimMappings { normalizedMappings[i] = mapping if mapping.OrgName != "" { normalizedMappings[i].OrgName = strings.ToLower(mapping.OrgName) } - if mapping.OrgAttribute != "" { - hasDynamicMapping = true - } } jwksCfg := cauth.NewJwksConfig( @@ -470,11 +460,6 @@ func (c *Config) GetOrInitJWTOriginConfig() *cauth.JWTOriginConfig { jwksCfg.JWKSTimeout = jwksTimeout jwksCfg.ClaimMappings = normalizedMappings - // Only assign reservedOrgNames to configs with dynamic claim mappings - if hasDynamicMapping { - jwksCfg.ReservedOrgNames = reservedOrgNames - } - c.JwtOriginConfig.AddJwksConfig(jwksCfg) } @@ -588,6 +573,12 @@ func (c *Config) ValidateIssuersConfig(issuers []IssuerConfig) error { if len(issuer.ClaimMappings) > 0 && origin != cauth.TokenOriginCustom { return fmt.Errorf("issuer %s: claimMappings are only allowed for custom origin issuers (origin: %s)", issuer.Name, origin) } + if origin == cauth.TokenOriginCustom && len(issuer.ClaimMappings) == 0 { + return fmt.Errorf("issuer %s: claimMappings are required for custom origin issuers", issuer.Name) + } + if origin == cauth.TokenOriginCustom && issuer.ServiceAccount { + return fmt.Errorf("issuer %s: serviceAccount is not supported for custom origin issuers; use claimMappings[].isServiceAccount", issuer.Name) + } // Validate JWKS timeout if specified if issuer.JWKSTimeout != "" { diff --git a/rest-api/api/internal/config/config_test.go b/rest-api/api/internal/config/config_test.go index d1bd8c767b..9e64ea431d 100644 --- a/rest-api/api/internal/config/config_test.go +++ b/rest-api/api/internal/config/config_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + cauth "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/config" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/viper" @@ -61,6 +62,92 @@ func TestNewConfig(t *testing.T) { } } +func TestValidateIssuersConfig_CustomClaimMappingRules(t *testing.T) { + cfg := &Config{v: newViper()} + + validCustom := IssuerConfig{ + Name: "external-idp", + Origin: cauth.TokenOriginCustom, + JWKS: "https://idp.example.com/jwks", + Issuer: "https://idp.example.com", + ClaimMappings: []cauth.ClaimMapping{{ + OrgName: "tenant-a", + Roles: []string{"TENANT_ADMIN"}, + }}, + } + + tests := []struct { + name string + issuer IssuerConfig + wantErr string + }{ + { + name: "custom issuer with mapping is valid", + issuer: validCustom, + }, + { + name: "custom issuer without mappings is invalid", + issuer: IssuerConfig{ + Name: "external-idp", + Origin: cauth.TokenOriginCustom, + JWKS: "https://idp.example.com/jwks", + Issuer: "https://idp.example.com", + }, + wantErr: "claimMappings are required", + }, + { + name: "non-custom issuer with mappings is invalid", + issuer: IssuerConfig{ + Name: "kas-idp", + Origin: cauth.TokenOriginKasLegacy, + JWKS: "https://idp.example.com/jwks", + Issuer: "https://idp.example.com", + ClaimMappings: []cauth.ClaimMapping{{ + OrgName: "tenant-a", + Roles: []string{"TENANT_ADMIN"}, + }}, + }, + wantErr: "claimMappings are only allowed for custom origin issuers", + }, + { + name: "custom issuer rejects issuer-level service account", + issuer: IssuerConfig{ + Name: "external-idp", + Origin: cauth.TokenOriginCustom, + JWKS: "https://idp.example.com/jwks", + Issuer: "https://idp.example.com", + ServiceAccount: true, + ClaimMappings: []cauth.ClaimMapping{{ + OrgName: "tenant-a", + Roles: []string{"TENANT_ADMIN"}, + }}, + }, + wantErr: "serviceAccount is not supported for custom origin issuers", + }, + { + name: "non-custom issuer without mappings remains valid", + issuer: IssuerConfig{ + Name: "kas-idp", + Origin: cauth.TokenOriginKasLegacy, + JWKS: "https://idp.example.com/jwks", + Issuer: "https://idp.example.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := cfg.ValidateIssuersConfig([]IssuerConfig{tt.issuer}) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + func TestConfig_WatchConfigFile(t *testing.T) { const initialSitePhoneHomeURL = "http://initial.example/phone_home" diff --git a/rest-api/api/internal/config/issuer.go b/rest-api/api/internal/config/issuer.go new file mode 100644 index 0000000000..6f98e7c456 --- /dev/null +++ b/rest-api/api/internal/config/issuer.go @@ -0,0 +1,219 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "context" + "fmt" + "strings" + "time" + + cauth "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/config" + cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +// dbClaimMappingsToAuth copies persisted claim mappings into the auth shape. +func dbClaimMappingsToAuth(in []cdbm.IssuerClaimMapping) []cauth.ClaimMapping { + out := make([]cauth.ClaimMapping, len(in)) + for i, cm := range in { + out[i] = cauth.ClaimMapping{ + OrgAttribute: cm.OrgAttribute, + OrgDisplayAttribute: cm.OrgDisplayAttribute, + OrgName: cm.OrgName, + OrgDisplayName: cm.OrgDisplayName, + RolesAttribute: cm.RolesAttribute, + Roles: cm.Roles, + IsServiceAccount: cm.IsServiceAccount, + } + } + return out +} + +// jwksConfigForIssuer builds a JwksConfig from a DB Issuer. +func (c *Config) jwksConfigForIssuer(iss *cdbm.Issuer) *cauth.JwksConfig { + jwksCfg := cauth.NewJwksConfig(iss.Name, iss.JWKSURL, iss.IssuerURL, iss.Origin, iss.ServiceAccount, iss.Audiences, iss.Scopes) + if iss.JWKSTimeout != "" { + d, err := time.ParseDuration(iss.JWKSTimeout) + if err == nil { + jwksCfg.JWKSTimeout = d + } + } + + mappings := dbClaimMappingsToAuth(iss.ClaimMappings) + for i := range mappings { + mappings[i].OrgName = strings.ToLower(mappings[i].OrgName) + } + jwksCfg.ClaimMappings = mappings + // The shared reserved-org set is wired in by AddJwksConfig when this config + // is installed (via ApplyIssuer/SeedIssuersFromDB). + return jwksCfg +} + +// issuerToConfig maps a DB Issuer back to an IssuerConfig for validation. +func (c *Config) issuerToConfig(iss *cdbm.Issuer) IssuerConfig { + return IssuerConfig{ + Name: iss.Name, + Origin: iss.Origin, + JWKS: iss.JWKSURL, + Issuer: iss.IssuerURL, + ServiceAccount: iss.ServiceAccount, + Audiences: iss.Audiences, + Scopes: iss.Scopes, + JWKSTimeout: iss.JWKSTimeout, + ClaimMappings: dbClaimMappingsToAuth(iss.ClaimMappings), + AllowDuplicateStaticOrgNames: iss.AllowDuplicateStaticOrgNames, + } +} + +// ValidateRegisteredIssuer validates candidate against the static config issuers +// and all registered DB issuers, excluding excludeID. +func (c *Config) ValidateRegisteredIssuer(ctx context.Context, dbSession *cdb.Session, tx *cdb.Tx, candidate *cdbm.Issuer, excludeID *uuid.UUID) error { + combined := c.GetIssuersConfig() + + existing, err := cdbm.NewIssuerDAO(dbSession).GetAll(ctx, tx) + if err != nil { + return err + } + for i := range existing { + if excludeID != nil && existing[i].ID == *excludeID { + continue + } + combined = append(combined, c.issuerToConfig(&existing[i])) + } + combined = append(combined, c.issuerToConfig(candidate)) + + return c.ValidateIssuersConfig(combined) +} + +// ApplyIssuer hot-applies a DB Issuer into the live JWT origin map. +func (c *Config) ApplyIssuer(iss *cdbm.Issuer) error { + joCfg := c.GetOrInitJWTOriginConfig() + if joCfg == nil { + return fmt.Errorf("JWT origin config not initialized") + } + jwksCfg := c.jwksConfigForIssuer(iss) + joCfg.AddJwksConfig(jwksCfg) + err := jwksCfg.UpdateJWKS() + if err != nil { + return err + } + return nil +} + +// RemoveIssuer removes an issuer from the live JWT origin map. +func (c *Config) RemoveIssuer(issuerURL string) { + if c.JwtOriginConfig != nil { + c.JwtOriginConfig.RemoveConfig(issuerURL) + } +} + +// configStaticOrgNames returns the lowercased static org names declared by the +// statically-configured issuers. Config is immutable after boot, so this is +// cheap to recompute on demand. +func (c *Config) configStaticOrgNames() map[string]bool { + out := make(map[string]bool) + for _, issuerCfg := range c.GetIssuersConfig() { + for _, mapping := range issuerCfg.ClaimMappings { + if mapping.OrgName != "" { + out[strings.ToLower(mapping.OrgName)] = true + } + } + } + return out +} + +// RebuildReservedOrgs recomputes the reserved org set from config and DB static orgs. +func (c *Config) RebuildReservedOrgs(ctx context.Context, dbSession *cdb.Session) error { + joCfg := c.GetOrInitJWTOriginConfig() + if joCfg == nil { + return fmt.Errorf("JWT origin config not initialized") + } + + issuers, err := cdbm.NewIssuerDAO(dbSession).GetAll(ctx, nil) + if err != nil { + return err + } + + union := c.configStaticOrgNames() + for i := range issuers { + for _, cm := range issuers[i].ClaimMappings { + if cm.OrgName != "" { + union[strings.ToLower(cm.OrgName)] = true + } + } + } + + joCfg.ReplaceReservedOrgs(union) + return nil +} + +// SeedIssuersFromDB applies all registered DB issuers into the live JWT origin map at startup. +// A statically-configured issuer URL is skipped; a JWKS fetch failure is non-fatal. +func (c *Config) SeedIssuersFromDB(ctx context.Context, dbSession *cdb.Session) error { + joCfg := c.GetOrInitJWTOriginConfig() + if joCfg == nil { + return fmt.Errorf("JWT origin config not initialized") + } + + issuers, err := cdbm.NewIssuerDAO(dbSession).GetAll(ctx, nil) + if err != nil { + return err + } + + issDAO := cdbm.NewIssuerDAO(dbSession) + for i := range issuers { + ctxErr := ctx.Err() + if ctxErr != nil { + return ctxErr + } + + iss := &issuers[i] + if c.IsStaticIssuer(iss.IssuerURL) { + log.Warn().Str("issuer", iss.IssuerURL).Msg("Skipping DB issuer that is statically configured") + continue + } + jwksCfg := c.jwksConfigForIssuer(iss) + joCfg.AddJwksConfig(jwksCfg) + status := cdbm.IssuerStatusReady + uerr := jwksCfg.UpdateJWKSWithContext(ctx) + if uerr != nil { + ctxErr = ctx.Err() + if ctxErr != nil { + return ctxErr + } + status = cdbm.IssuerStatusPending + log.Warn().Err(uerr).Str("issuer", iss.IssuerURL). + Msg("Failed to fetch JWKS for DB-registered issuer at boot; will lazy-refresh on first use") + } + if iss.Status != status { + _, uerr = cdb.WithTxResult(ctx, dbSession, func(tx *cdb.Tx) (*cdbm.Issuer, error) { + return issDAO.Update(ctx, tx, cdbm.IssuerUpdateInput{IssuerID: iss.ID, Status: &status}) + }) + if uerr != nil { + return uerr + } + } + } + + rerr := c.RebuildReservedOrgs(ctx, dbSession) + if rerr != nil { + return rerr + } + + log.Info().Int("count", len(issuers)).Msg("Seeded DB-registered issuers into JWT origin config") + return nil +} + +// IsStaticIssuer reports whether the given issuer URL is configured as a static issuer. +func (c *Config) IsStaticIssuer(issuerURL string) bool { + for _, issuerCfg := range c.GetIssuersConfig() { + if issuerCfg.Issuer == issuerURL { + return true + } + } + return false +} diff --git a/rest-api/api/internal/server/server.go b/rest-api/api/internal/server/server.go index cd39f6a580..2561afcd21 100644 --- a/rest-api/api/internal/server/server.go +++ b/rest-api/api/internal/server/server.go @@ -4,6 +4,7 @@ package server import ( + "context" "fmt" "net/http" "os" @@ -259,6 +260,14 @@ func InitAPIServer(cfg *config.Config, dbSession *cdb.Session, tc tsdkClient.Cli log.Panic().Msg("JWT origin config not initialized, cannot initialize auth middleware") } + // Seed DB-registered issuers into the live JWT origin map before auth routes serve traffic. + seedCtx, cancelSeed := context.WithTimeout(context.Background(), 30*time.Second) + defer cancelSeed() + err := cfg.SeedIssuersFromDB(seedCtx, dbSession) + if err != nil { + log.Panic().Err(err).Msg("Failed to seed DB-registered issuers into JWT origin config") + } + keycloakConfig, _ := cfg.GetOrInitKeycloakConfig() payloadEncryptionConfig := cconfig.NewPayloadEncryptionConfig(cfg.GetTemporalEncryptionKey()) diff --git a/rest-api/auth/pkg/config/jwks.go b/rest-api/auth/pkg/config/jwks.go index 1fec45d65d..f0600520fa 100644 --- a/rest-api/auth/pkg/config/jwks.go +++ b/rest-api/auth/pkg/config/jwks.go @@ -158,13 +158,42 @@ type JwksConfig struct { // For custom issuers, use ClaimMapping.IsServiceAccount instead. ServiceAccount bool - // ReservedOrgNames prevents dynamic org mappings from claiming statically-configured org names. - // Populated by nico-rest-api during initialization. - ReservedOrgNames map[string]bool + // ReservedOrgNames blocks dynamic org mappings from claiming reserved (statically-pinned) org names. + ReservedOrgNames *ReservedOrgSet subjectPrefix string // SHA256(issuer)[0:10] - namespaces subject claims } +// ReservedOrgSet is a thread-safe set of reserved org names. +type ReservedOrgSet struct { + mu sync.RWMutex + m map[string]bool +} + +// NewReservedOrgSet returns an empty ReservedOrgSet. +func NewReservedOrgSet() *ReservedOrgSet { + return &ReservedOrgSet{m: make(map[string]bool)} +} + +// Has reports whether org is reserved. +func (s *ReservedOrgSet) Has(org string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.m[org] +} + +// Replace swaps the set's contents for m. +func (s *ReservedOrgSet) Replace(m map[string]bool) { + next := make(map[string]bool, len(m)) + for org, reserved := range m { + next[org] = reserved + } + + s.mu.Lock() + defer s.mu.Unlock() + s.m = next +} + // NewJwksConfig is a function that initializes and returns a configuration object for managing JWKS func NewJwksConfig(name string, url string, issuer string, origin string, serviceAccount bool, audiences []string, scopes []string) *JwksConfig { // Default to custom origin if not specified @@ -253,6 +282,14 @@ func (jcfg *JwksConfig) shouldAllowJWKSUpdate() bool { // UpdateJWKS fetches and validates JWKS from the configured URL. Throttled to minUpdateInterval. func (jcfg *JwksConfig) UpdateJWKS() error { + return jcfg.UpdateJWKSWithContext(context.Background()) +} + +// UpdateJWKSWithContext fetches and validates JWKS, bounded by ctx and the configured JWKS timeout. +func (jcfg *JwksConfig) UpdateJWKSWithContext(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } if jcfg.URL == "" { return core.ErrJWKSURLEmpty } @@ -268,7 +305,7 @@ func (jcfg *JwksConfig) UpdateJWKS() error { urlCopy, timeout := jcfg.URL, jcfg.JWKSTimeout jcfg.RUnlock() - jwks, err := core.NewJWKSFromURL(urlCopy, timeout) + jwks, err := core.NewJWKSFromURLWithContext(ctx, urlCopy, timeout) if err != nil { return errors.Wrapf(err, "failed to update JWKS from %s", urlCopy) } @@ -581,7 +618,7 @@ func (jcfg *JwksConfig) GetOrgDataFromClaim(claims jwt.MapClaims, reqOrgFromRout continue } - if cm.IsOrgDynamic() && jcfg.ReservedOrgNames != nil && jcfg.ReservedOrgNames[orgName] { + if cm.IsOrgDynamic() && jcfg.ReservedOrgNames != nil && jcfg.ReservedOrgNames.Has(orgName) { return nil, false, core.ErrReservedOrgName } diff --git a/rest-api/auth/pkg/config/jwks_test.go b/rest-api/auth/pkg/config/jwks_test.go index 61c6ccc748..f85011a272 100644 --- a/rest-api/auth/pkg/config/jwks_test.go +++ b/rest-api/auth/pkg/config/jwks_test.go @@ -4,6 +4,7 @@ package config import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -243,6 +244,29 @@ func TestJwksConfig_UpdateJWKs(t *testing.T) { } } +func TestJwksConfig_UpdateJWKSWithContextHonorsCallerCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-r.Context().Done() + })) + defer server.Close() + + config := &JwksConfig{ + URL: server.URL, + Issuer: "test.example.com", + } + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + + start := time.Now() + err := config.UpdateJWKSWithContext(ctx) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "context deadline exceeded") + assert.Nil(t, config.GetJWKS()) + assert.Less(t, time.Since(start), 500*time.Millisecond) +} + // TestJwksConfig_Concurrency tests thread safety of JWKS operations func TestJwksConfig_Concurrency(t *testing.T) { // Create a test server that returns valid JWKS diff --git a/rest-api/auth/pkg/config/jwtOrigin.go b/rest-api/auth/pkg/config/jwtOrigin.go index 9a3b05a3fe..5a31a69a13 100644 --- a/rest-api/auth/pkg/config/jwtOrigin.go +++ b/rest-api/auth/pkg/config/jwtOrigin.go @@ -32,16 +32,18 @@ type TokenProcessor interface { // JWTOriginConfig holds configuration for JWT origins with multiple JWKS configs and handlers type JWTOriginConfig struct { - sync.RWMutex // protects concurrent access to configs and handlers maps - configs map[string]*JwksConfig // map issuer -> JWKSConfig - processors map[string]TokenProcessor // map TokenOrigin -> TokenProcessor + sync.RWMutex // protects concurrent access to configs and handlers maps + configs map[string]*JwksConfig // map issuer -> JWKSConfig + processors map[string]TokenProcessor // map TokenOrigin -> TokenProcessor + reservedOrgNames *ReservedOrgSet // shared reserved-org set wired into dynamic JwksConfigs } // NewJWTOriginConfig initializes and returns a configuration object with empty maps func NewJWTOriginConfig() *JWTOriginConfig { return &JWTOriginConfig{ - configs: make(map[string]*JwksConfig), - processors: make(map[string]TokenProcessor), + configs: make(map[string]*JwksConfig), + processors: make(map[string]TokenProcessor), + reservedOrgNames: NewReservedOrgSet(), } } @@ -50,6 +52,7 @@ func NewJWTOriginConfig() *JWTOriginConfig { func (jc *JWTOriginConfig) AddJwksConfig(cfg *JwksConfig) { jc.Lock() defer jc.Unlock() + cfg.ReservedOrgNames = jc.reservedOrgNames // shared; only consulted for dynamic mappings jc.configs[cfg.Issuer] = cfg } @@ -139,6 +142,13 @@ func (jc *JWTOriginConfig) GetAllConfigs() map[string]*JwksConfig { return jc.configs } +// ReplaceReservedOrgs swaps the contents of the shared reserved-org set wired +// into every JwksConfig. The set is created once in NewJWTOriginConfig and locks +// itself, so callers mutate it in place rather than reassigning the field. +func (jc *JWTOriginConfig) ReplaceReservedOrgs(orgs map[string]bool) { + jc.reservedOrgNames.Replace(orgs) +} + // UpdateAllJWKS updates the JWKs for all configurations in the map // Updates are performed in parallel for better performance with multiple issuers. // Continues on individual failures - only returns error if ALL updates fail. diff --git a/rest-api/auth/pkg/core/jwks.go b/rest-api/auth/pkg/core/jwks.go index 50007def0c..7b82df5eeb 100644 --- a/rest-api/auth/pkg/core/jwks.go +++ b/rest-api/auth/pkg/core/jwks.go @@ -26,15 +26,24 @@ type JWKS struct { // NewJWKSFromURL creates a new set of JSON Web Keys given a URL using go-jose // If timeout is zero or negative, uses the default timeout of 5 seconds func NewJWKSFromURL(url string, timeout time.Duration) (*JWKS, error) { + return NewJWKSFromURLWithContext(context.Background(), url, timeout) +} + +// NewJWKSFromURLWithContext creates a new set of JSON Web Keys and bounds the +// request by both the caller context and the configured timeout. +func NewJWKSFromURLWithContext(ctx context.Context, url string, timeout time.Duration) (*JWKS, error) { + if ctx == nil { + ctx = context.Background() + } if timeout <= 0 { timeout = DefaultJWKSTimeout } client := &http.Client{} - ctx, cancel := context.WithTimeout(context.Background(), timeout) + reqCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + req, err := http.NewRequestWithContext(reqCtx, "GET", url, nil) if err != nil { log.Error().Err(err).Msgf("failed to create request for JWKS URL: %s", url) return nil, errors.Wrap(ErrJWKSFetch, err.Error()) diff --git a/rest-api/auth/pkg/core/jwks_test.go b/rest-api/auth/pkg/core/jwks_test.go index 3e455abffc..14b31f5d43 100644 --- a/rest-api/auth/pkg/core/jwks_test.go +++ b/rest-api/auth/pkg/core/jwks_test.go @@ -4,6 +4,7 @@ package core import ( + "context" "crypto/ecdsa" "crypto/rsa" "net/http" @@ -239,6 +240,24 @@ func TestNewJWKSFromURL(t *testing.T) { } } +func TestNewJWKSFromURLWithContextHonorsCallerCancellation(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + <-req.Context().Done() + })) + defer testServer.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + + start := time.Now() + jwks, err := NewJWKSFromURLWithContext(ctx, testServer.URL, 5*time.Second) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "context deadline exceeded") + assert.Nil(t, jwks) + assert.Less(t, time.Since(start), 500*time.Millisecond) +} + func TestJWKS_GetKIDPublicKeyMap_EmptyKeys(t *testing.T) { emptyJwks := `{"keys":[]}` From 679ddc43f5716ed911e3bbac280ad4fd1ac5a523 Mon Sep 17 00:00:00 2001 From: Jan Baraniewski Date: Fri, 19 Jun 2026 16:53:17 +0200 Subject: [PATCH 3/4] feat(api): add provider-admin issuer registration endpoints Expose CRUD for OIDC issuer bindings under /org/{org}/nico/issuer, restricted to Provider Admins. Create and Update validate the candidate against the combined static and registered issuer set before persisting, hot-apply the binding into the live JWT origin map, settle its status from the JWKS fetch, and recompute the reserved-org set; Delete removes it from the live map. A statically configured issuer URL cannot be registered, and a duplicate active issuer URL returns 409. Signed-off-by: Jan Baraniewski --- rest-api/api/pkg/api/handler/issuer.go | 632 ++++++++++++++ rest-api/api/pkg/api/handler/issuer_test.go | 271 ++++++ rest-api/api/pkg/api/model/error.go | 2 + rest-api/api/pkg/api/model/issuer.go | 222 +++++ rest-api/api/pkg/api/routes.go | 26 + rest-api/openapi/spec.yaml | 418 ++++++++++ rest-api/sdk/standard/api_issuer.go | 771 ++++++++++++++++++ rest-api/sdk/standard/client.go | 3 + rest-api/sdk/standard/model_bmc_credential.go | 3 + .../model_deletion_accepted_response.go | 3 + rest-api/sdk/standard/model_issuer.go | 570 +++++++++++++ .../standard/model_issuer_claim_mapping.go | 354 ++++++++ .../standard/model_issuer_create_request.go | 481 +++++++++++ rest-api/sdk/standard/model_issuer_status.go | 113 +++ .../standard/model_issuer_update_request.go | 442 ++++++++++ 15 files changed, 4311 insertions(+) create mode 100644 rest-api/api/pkg/api/handler/issuer.go create mode 100644 rest-api/api/pkg/api/handler/issuer_test.go create mode 100644 rest-api/api/pkg/api/model/issuer.go create mode 100644 rest-api/sdk/standard/api_issuer.go create mode 100644 rest-api/sdk/standard/model_issuer.go create mode 100644 rest-api/sdk/standard/model_issuer_claim_mapping.go create mode 100644 rest-api/sdk/standard/model_issuer_create_request.go create mode 100644 rest-api/sdk/standard/model_issuer_status.go create mode 100644 rest-api/sdk/standard/model_issuer_update_request.go diff --git a/rest-api/api/pkg/api/handler/issuer.go b/rest-api/api/pkg/api/handler/issuer.go new file mode 100644 index 0000000000..c31bac8500 --- /dev/null +++ b/rest-api/api/pkg/api/handler/issuer.go @@ -0,0 +1,632 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "go.opentelemetry.io/otel/attribute" + + validation "github.com/go-ozzo/ozzo-validation/v4" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog" + + "github.com/NVIDIA/infra-controller/rest-api/api/internal/config" + common "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model" + auth "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/authorization" + cauth "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/config" + cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util" + cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" +) + +const issuerRegistrationLockName = "issuer-registration" + +func acquireIssuerRegistrationLock(ctx context.Context, tx *cdb.Tx) error { + return tx.TryAcquireAdvisoryLock(ctx, cdb.GetAdvisoryLockIDFromString(issuerRegistrationLockName), nil) +} + +func bindIssuerUpdateRequest(c echo.Context, apiRequest *model.APIIssuerUpdateRequest) (string, error) { + body, err := io.ReadAll(c.Request().Body) + if err != nil { + return "", err + } + + raw := map[string]json.RawMessage{} + err = json.Unmarshal(body, &raw) + if err != nil { + return "", err + } + + for _, field := range []string{"issuerUrl", "origin"} { + _, ok := raw[field] + if ok { + return field, nil + } + } + + return "", json.Unmarshal(body, apiRequest) +} + +func overlayIssuerUpdate(existing *cdbm.Issuer, apiRequest model.APIIssuerUpdateRequest, claimMappings []cdbm.IssuerClaimMapping) cdbm.Issuer { + candidate := *existing + if apiRequest.Name != nil { + candidate.Name = *apiRequest.Name + } + if apiRequest.JWKSURL != nil { + candidate.JWKSURL = *apiRequest.JWKSURL + } + if apiRequest.ServiceAccount != nil { + candidate.ServiceAccount = *apiRequest.ServiceAccount + } + if apiRequest.Audiences != nil { + candidate.Audiences = apiRequest.Audiences + } + if apiRequest.Scopes != nil { + candidate.Scopes = apiRequest.Scopes + } + if apiRequest.JWKSTimeout != nil { + candidate.JWKSTimeout = *apiRequest.JWKSTimeout + } + if claimMappings != nil { + candidate.ClaimMappings = claimMappings + } + if apiRequest.AllowDuplicateStaticOrgNames != nil { + candidate.AllowDuplicateStaticOrgNames = *apiRequest.AllowDuplicateStaticOrgNames + } + return candidate +} + +// applyIssuerAndReconcile hot-applies iss into the live JWT origin map, settles +// its persisted status (Ready when the JWKS is reachable, Pending otherwise, +// writing back only on change), and rebuilds the reserved-org set. It returns +// the issuer reflecting any status change. Shared by the create and update +// handlers, which previously duplicated this block. +func applyIssuerAndReconcile(ctx context.Context, cfg *config.Config, dbSession *cdb.Session, iss *cdbm.Issuer, logger zerolog.Logger) *cdbm.Issuer { + status := cdbm.IssuerStatusReady + aerr := cfg.ApplyIssuer(iss) + if aerr != nil { + logger.Warn().Err(aerr).Str("issuer_url", iss.IssuerURL).Msg("issuer JWKS not yet reachable; persisting as Pending") + status = cdbm.IssuerStatusPending + } + if status != iss.Status { + issDAO := cdbm.NewIssuerDAO(dbSession) + updated, uerr := cdb.WithTxResult(ctx, dbSession, func(tx *cdb.Tx) (*cdbm.Issuer, error) { + return issDAO.Update(ctx, tx, cdbm.IssuerUpdateInput{IssuerID: iss.ID, Status: &status}) + }) + if uerr != nil { + logger.Error().Err(uerr).Str("issuer_url", iss.IssuerURL).Msg("failed to update issuer status after apply") + } else { + iss = updated + } + } + rerr := cfg.RebuildReservedOrgs(ctx, dbSession) + if rerr != nil { + logger.Error().Err(rerr).Msg("failed to rebuild reserved org set after issuer apply") + } + return iss +} + +// ~~~~~ Create Handler ~~~~~ // + +// CreateIssuerHandler is the API Handler for registering a new Issuer binding +type CreateIssuerHandler struct { + dbSession *cdb.Session + cfg *config.Config + tracerSpan *cutil.TracerSpan +} + +// NewCreateIssuerHandler initializes and returns a new handler for registering an Issuer +func NewCreateIssuerHandler(dbSession *cdb.Session, cfg *config.Config) CreateIssuerHandler { + return CreateIssuerHandler{ + dbSession: dbSession, + cfg: cfg, + tracerSpan: cutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Register an OIDC issuer binding +// @Description Register an OIDC issuer-to-org trust binding. Provider Admin only. +// @Tags Issuer +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization (provider org)" +// @Param message body model.APIIssuerCreateRequest true "Issuer registration request" +// @Success 201 {object} model.APIIssuer +// @Router /v2/org/{org}/nico/issuer [post] +func (cih CreateIssuerHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("Issuer", "Create", c, cih.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate org + ok, err := auth.ValidateOrgMembership(dbUser, org) + if !ok { + if err != nil { + logger.Error().Err(err).Msg("error validating org membership for User in request") + } else { + logger.Warn().Msg("could not validate org membership for user, access denied") + } + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) + } + + // Validate role, only Provider Admins are allowed to interact with Issuer endpoints + ok = auth.ValidateUserRoles(dbUser, org, nil, auth.ProviderAdminRole) + if !ok { + logger.Warn().Msg("user does not have Provider Admin role, access denied") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Provider Admin role with org", nil) + } + + apiRequest := model.APIIssuerCreateRequest{} + err = c.Bind(&apiRequest) + if err != nil { + logger.Warn().Err(err).Msg("error binding request data into API model") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to parse request data, potentially invalid structure", nil) + } + verr := apiRequest.Validate() + if verr != nil { + logger.Warn().Err(verr).Msg("error validating Issuer creation request data") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Error validating Issuer creation request data", verr) + } + + cih.tracerSpan.SetAttribute(handlerSpan, attribute.String("issuer_url", apiRequest.IssuerURL), logger) + + // a statically-configured issuer cannot be registered + if cih.cfg.IsStaticIssuer(apiRequest.IssuerURL) { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Cannot register a statically-configured issuer", nil) + } + + origin := apiRequest.Origin + if origin == "" { + origin = cauth.TokenOriginCustom + } else { + normalized, oerr := config.ParseOriginString(origin) + if oerr != nil { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("Invalid origin: %s", origin), nil) + } + origin = normalized + } + if origin != cauth.TokenOriginCustom { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Dynamic issuer registration only supports custom origins", nil) + } + + candidate := &cdbm.Issuer{ + Name: apiRequest.Name, + IssuerURL: apiRequest.IssuerURL, + JWKSURL: apiRequest.JWKSURL, + Origin: origin, + ServiceAccount: apiRequest.ServiceAccount, + Audiences: apiRequest.Audiences, + Scopes: apiRequest.Scopes, + JWKSTimeout: apiRequest.JWKSTimeout, + ClaimMappings: model.APIClaimMappings(apiRequest.ClaimMappings).ToDB(), + AllowDuplicateStaticOrgNames: apiRequest.AllowDuplicateStaticOrgNames, + } + + issDAO := cdbm.NewIssuerDAO(cih.dbSession) + + iss, err := cdb.WithTxResult(ctx, cih.dbSession, func(tx *cdb.Tx) (*cdbm.Issuer, error) { + derr := acquireIssuerRegistrationLock(ctx, tx) + if derr != nil { + logger.Error().Err(derr).Msg("failed to acquire issuer registration advisory lock") + return nil, cutil.NewAPIError(http.StatusInternalServerError, "Failed to register issuer, unable to acquire lock", nil) + } + + verr := cih.cfg.ValidateRegisteredIssuer(ctx, cih.dbSession, tx, candidate, nil) + if verr != nil { + logger.Warn().Err(verr).Msg("issuer registration rejected by config validation") + return nil, cutil.NewAPIError(http.StatusBadRequest, fmt.Sprintf("Invalid issuer registration: %s", verr), nil) + } + + return issDAO.Create(ctx, tx, cdbm.IssuerCreateInput{ + Name: apiRequest.Name, + IssuerURL: apiRequest.IssuerURL, + JWKSURL: apiRequest.JWKSURL, + Origin: origin, + ServiceAccount: apiRequest.ServiceAccount, + Audiences: apiRequest.Audiences, + Scopes: apiRequest.Scopes, + JWKSTimeout: apiRequest.JWKSTimeout, + ClaimMappings: candidate.ClaimMappings, + AllowDuplicateStaticOrgNames: apiRequest.AllowDuplicateStaticOrgNames, + Status: cdbm.IssuerStatusPending, + CreatedBy: dbUser.ID, + }) + }) + if err != nil { + if cih.dbSession.GetErrorChecker().IsUniqueConstraintError(err) { + return cutil.NewAPIErrorResponse(c, http.StatusConflict, "An issuer with this issuerUrl is already registered", validation.Errors{ + "issuerUrl": errors.New("already registered"), + }) + } + return common.HandleTxError(c, logger, err, "Failed to register issuer due to DB transaction error") + } + + // Hot-apply into the live JWT origin map and settle status + reserved orgs. + iss = applyIssuerAndReconcile(ctx, cih.cfg, cih.dbSession, iss, logger) + + logger.Info().Str("issuer_url", iss.IssuerURL).Str("status", iss.Status).Msg("finishing API handler") + return c.JSON(http.StatusCreated, model.NewAPIIssuer(iss)) +} + +// ~~~~~ GetAll Handler ~~~~~ // + +// GetAllIssuerHandler is the API Handler for listing registered Issuers +type GetAllIssuerHandler struct { + dbSession *cdb.Session + cfg *config.Config + tracerSpan *cutil.TracerSpan +} + +// NewGetAllIssuerHandler initializes and returns a new handler for listing Issuers +func NewGetAllIssuerHandler(dbSession *cdb.Session, cfg *config.Config) GetAllIssuerHandler { + return GetAllIssuerHandler{ + dbSession: dbSession, + cfg: cfg, + tracerSpan: cutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary List registered OIDC issuer bindings +// @Description List all registered OIDC issuer bindings. Provider Admin only. +// @Tags Issuer +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization (provider org)" +// @Success 200 {array} []model.APIIssuer +// @Router /v2/org/{org}/nico/issuer [get] +func (gaih GetAllIssuerHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("Issuer", "GetAll", c, gaih.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate org + ok, err := auth.ValidateOrgMembership(dbUser, org) + if !ok { + if err != nil { + logger.Error().Err(err).Msg("error validating org membership for User in request") + } else { + logger.Warn().Msg("could not validate org membership for user, access denied") + } + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) + } + + // Validate role, only Provider Admins are allowed to interact with Issuer endpoints + ok = auth.ValidateUserRoles(dbUser, org, nil, auth.ProviderAdminRole) + if !ok { + logger.Warn().Msg("user does not have Provider Admin role, access denied") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Provider Admin role with org", nil) + } + + issuers, err := cdbm.NewIssuerDAO(gaih.dbSession).GetAll(ctx, nil) + if err != nil { + logger.Error().Err(err).Msg("error retrieving Issuers") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve issuers due to data store error", nil) + } + + apiIssuers := []model.APIIssuer{} + for i := range issuers { + apiIssuers = append(apiIssuers, *model.NewAPIIssuer(&issuers[i])) + } + + logger.Info().Msg("finishing API handler") + return c.JSON(http.StatusOK, apiIssuers) +} + +// ~~~~~ Get Handler ~~~~~ // + +// GetIssuerHandler is the API Handler for getting a single Issuer +type GetIssuerHandler struct { + dbSession *cdb.Session + cfg *config.Config + tracerSpan *cutil.TracerSpan +} + +// NewGetIssuerHandler initializes and returns a new handler for getting an Issuer +func NewGetIssuerHandler(dbSession *cdb.Session, cfg *config.Config) GetIssuerHandler { + return GetIssuerHandler{ + dbSession: dbSession, + cfg: cfg, + tracerSpan: cutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Get a registered OIDC issuer binding +// @Description Get a registered OIDC issuer binding by ID. Provider Admin only. +// @Tags Issuer +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization (provider org)" +// @Param id path string true "ID of Issuer" +// @Success 200 {object} model.APIIssuer +// @Router /v2/org/{org}/nico/issuer/{id} [get] +func (gih GetIssuerHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("Issuer", "Get", c, gih.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate org + ok, err := auth.ValidateOrgMembership(dbUser, org) + if !ok { + if err != nil { + logger.Error().Err(err).Msg("error validating org membership for User in request") + } else { + logger.Warn().Msg("could not validate org membership for user, access denied") + } + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) + } + + // Validate role, only Provider Admins are allowed to interact with Issuer endpoints + ok = auth.ValidateUserRoles(dbUser, org, nil, auth.ProviderAdminRole) + if !ok { + logger.Warn().Msg("user does not have Provider Admin role, access denied") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Provider Admin role with org", nil) + } + + issID, err := uuid.Parse(c.Param("id")) + if err != nil { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Issuer ID in URL", nil) + } + + iss, err := cdbm.NewIssuerDAO(gih.dbSession).GetByID(ctx, nil, issID) + if err != nil { + if err == cdb.ErrDoesNotExist { + return cutil.NewAPIErrorResponse(c, http.StatusNotFound, "Could not find issuer", nil) + } + logger.Error().Err(err).Msg("error retrieving Issuer from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve issuer due to data store error", nil) + } + + logger.Info().Msg("finishing API handler") + return c.JSON(http.StatusOK, model.NewAPIIssuer(iss)) +} + +// ~~~~~ Update Handler ~~~~~ // + +// UpdateIssuerHandler is the API Handler for updating an Issuer binding +type UpdateIssuerHandler struct { + dbSession *cdb.Session + cfg *config.Config + tracerSpan *cutil.TracerSpan +} + +// NewUpdateIssuerHandler initializes and returns a new handler for updating an Issuer +func NewUpdateIssuerHandler(dbSession *cdb.Session, cfg *config.Config) UpdateIssuerHandler { + return UpdateIssuerHandler{ + dbSession: dbSession, + cfg: cfg, + tracerSpan: cutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Update a registered OIDC issuer binding +// @Description Update a registered OIDC issuer binding (JWKS URL, roles, audiences). Provider Admin only. +// @Tags Issuer +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization (provider org)" +// @Param id path string true "ID of Issuer" +// @Param message body model.APIIssuerUpdateRequest true "Issuer update request" +// @Success 200 {object} model.APIIssuer +// @Router /v2/org/{org}/nico/issuer/{id} [patch] +func (uih UpdateIssuerHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("Issuer", "Update", c, uih.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate org + ok, err := auth.ValidateOrgMembership(dbUser, org) + if !ok { + if err != nil { + logger.Error().Err(err).Msg("error validating org membership for User in request") + } else { + logger.Warn().Msg("could not validate org membership for user, access denied") + } + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) + } + + // Validate role, only Provider Admins are allowed to interact with Issuer endpoints + ok = auth.ValidateUserRoles(dbUser, org, nil, auth.ProviderAdminRole) + if !ok { + logger.Warn().Msg("user does not have Provider Admin role, access denied") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Provider Admin role with org", nil) + } + + issID, err := uuid.Parse(c.Param("id")) + if err != nil { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Issuer ID in URL", nil) + } + + apiRequest := model.APIIssuerUpdateRequest{} + immutableField, err := bindIssuerUpdateRequest(c, &apiRequest) + if err != nil { + logger.Warn().Err(err).Msg("error binding request data into API model") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Failed to parse request data, potentially invalid structure", nil) + } + if immutableField != "" { + logger.Warn().Str("field", immutableField).Msg("immutable issuer field specified in update request") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Issuer update contains immutable field", validation.Errors{ + immutableField: errors.New("field is immutable"), + }) + } + verr := apiRequest.Validate() + if verr != nil { + logger.Warn().Err(verr).Msg("error validating Issuer update request data") + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Error validating Issuer update data", verr) + } + + issDAO := cdbm.NewIssuerDAO(uih.dbSession) + claimMappings := model.APIClaimMappings(apiRequest.ClaimMappings).ToDB() + + iss, err := cdb.WithTxResult(ctx, uih.dbSession, func(tx *cdb.Tx) (*cdbm.Issuer, error) { + derr := acquireIssuerRegistrationLock(ctx, tx) + if derr != nil { + logger.Error().Err(derr).Msg("failed to acquire issuer registration advisory lock") + return nil, cutil.NewAPIError(http.StatusInternalServerError, "Failed to update issuer, unable to acquire lock", nil) + } + + existing, derr := issDAO.GetByID(ctx, tx, issID) + if derr != nil { + if derr == cdb.ErrDoesNotExist { + return nil, cutil.NewAPIError(http.StatusNotFound, "Could not find issuer to update", nil) + } + logger.Error().Err(derr).Msg("error retrieving Issuer from DB") + return nil, cutil.NewAPIError(http.StatusInternalServerError, "Could not find issuer due to data store error", nil) + } + + candidate := overlayIssuerUpdate(existing, apiRequest, claimMappings) + verr := uih.cfg.ValidateRegisteredIssuer(ctx, uih.dbSession, tx, &candidate, &issID) + if verr != nil { + logger.Warn().Err(verr).Msg("issuer update rejected by config validation") + return nil, cutil.NewAPIError(http.StatusBadRequest, fmt.Sprintf("Invalid issuer update: %s", verr), nil) + } + + return issDAO.Update(ctx, tx, cdbm.IssuerUpdateInput{ + IssuerID: issID, + Name: apiRequest.Name, + JWKSURL: apiRequest.JWKSURL, + ServiceAccount: apiRequest.ServiceAccount, + Audiences: apiRequest.Audiences, + Scopes: apiRequest.Scopes, + JWKSTimeout: apiRequest.JWKSTimeout, + ClaimMappings: claimMappings, + AllowDuplicateStaticOrgNames: apiRequest.AllowDuplicateStaticOrgNames, + }) + }) + if err != nil { + return common.HandleTxError(c, logger, err, "Failed to update issuer due to DB transaction error") + } + + // Re-apply into the live JWT origin map and settle status + reserved orgs. + iss = applyIssuerAndReconcile(ctx, uih.cfg, uih.dbSession, iss, logger) + + logger.Info().Str("issuer_url", iss.IssuerURL).Str("status", iss.Status).Msg("finishing API handler") + return c.JSON(http.StatusOK, model.NewAPIIssuer(iss)) +} + +// ~~~~~ Delete Handler ~~~~~ // + +// DeleteIssuerHandler is the API Handler for deregistering an Issuer binding +type DeleteIssuerHandler struct { + dbSession *cdb.Session + cfg *config.Config + tracerSpan *cutil.TracerSpan +} + +// NewDeleteIssuerHandler initializes and returns a new handler for deregistering an Issuer +func NewDeleteIssuerHandler(dbSession *cdb.Session, cfg *config.Config) DeleteIssuerHandler { + return DeleteIssuerHandler{ + dbSession: dbSession, + cfg: cfg, + tracerSpan: cutil.NewTracerSpan(), + } +} + +// Handle godoc +// @Summary Deregister an OIDC issuer binding +// @Description Deregister (soft-delete) an OIDC issuer binding. Provider Admin only. +// @Tags Issuer +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param org path string true "Name of NGC organization (provider org)" +// @Param id path string true "ID of Issuer" +// @Success 202 +// @Router /v2/org/{org}/nico/issuer/{id} [delete] +func (dih DeleteIssuerHandler) Handle(c echo.Context) error { + org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("Issuer", "Delete", c, dih.tracerSpan) + if handlerSpan != nil { + defer handlerSpan.End() + } + + if dbUser == nil { + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil) + } + + // Validate org + ok, err := auth.ValidateOrgMembership(dbUser, org) + if !ok { + if err != nil { + logger.Error().Err(err).Msg("error validating org membership for User in request") + } else { + logger.Warn().Msg("could not validate org membership for user, access denied") + } + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil) + } + + // Validate role, only Provider Admins are allowed to interact with Issuer endpoints + ok = auth.ValidateUserRoles(dbUser, org, nil, auth.ProviderAdminRole) + if !ok { + logger.Warn().Msg("user does not have Provider Admin role, access denied") + return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Provider Admin role with org", nil) + } + + issID, err := uuid.Parse(c.Param("id")) + if err != nil { + return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Issuer ID in URL", nil) + } + + issDAO := cdbm.NewIssuerDAO(dih.dbSession) + iss, err := issDAO.GetByID(ctx, nil, issID) + if err != nil { + if err == cdb.ErrDoesNotExist { + return cutil.NewAPIErrorResponse(c, http.StatusNotFound, "Could not find issuer with specified ID", nil) + } + logger.Error().Err(err).Msg("error retrieving Issuer from DB") + return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve issuer due to data store error", nil) + } + + err = cdb.WithTx(ctx, dih.dbSession, func(tx *cdb.Tx) error { + return issDAO.Delete(ctx, tx, issID) + }) + if err != nil { + return common.HandleTxError(c, logger, err, "Failed to deregister issuer due to DB transaction error") + } + + // Remove from the live map. + dih.cfg.RemoveIssuer(iss.IssuerURL) + + rerr := dih.cfg.RebuildReservedOrgs(ctx, dih.dbSession) + if rerr != nil { + logger.Error().Err(rerr).Msg("failed to rebuild reserved org set after issuer delete") + } + + logger.Info().Str("issuer_url", iss.IssuerURL).Msg("finishing API handler") + return c.String(http.StatusAccepted, "Deletion request was accepted") +} diff --git a/rest-api/api/pkg/api/handler/issuer_test.go b/rest-api/api/pkg/api/handler/issuer_test.go new file mode 100644 index 0000000000..d20e52b958 --- /dev/null +++ b/rest-api/api/pkg/api/handler/issuer_test.go @@ -0,0 +1,271 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common" + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model" + authz "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/authorization" + cauth "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/config" + "github.com/NVIDIA/infra-controller/rest-api/common/pkg/otelecho" + cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testIssuerSetupSchema(t *testing.T, dbSession *cdb.Session) { + // Only the tables these tests touch: User (for the authenticated caller) and Issuer. + err := dbSession.DB.ResetModel(context.Background(), (*cdbm.User)(nil)) + assert.Nil(t, err) + err = dbSession.DB.ResetModel(context.Background(), (*cdbm.Issuer)(nil)) + assert.Nil(t, err) +} + +// stub JWKS endpoint returning an empty key set so ApplyIssuer's UpdateJWKS +// fails deterministically (no network/DNS), leaving the row Pending. +func testIssuerJWKSStub(t *testing.T) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"keys":[]}`)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestIssuerHandler_Create(t *testing.T) { + ctx := context.Background() + dbSession := testInstanceInitDB(t) + defer dbSession.Close() + testIssuerSetupSchema(t, dbSession) + + cfg := common.GetTestConfig() + cfg.JwtOriginConfig = cauth.NewJWTOriginConfig() + tracer, _, ctx := common.TestCommonTraceProviderSetup(t, ctx) + + jwks := testIssuerJWKSStub(t) + + providerOrg := "test-provider-org" + providerAdmin := testInstanceBuildUser(t, dbSession, uuid.New().String(), providerOrg, []string{authz.ProviderAdminRole}) + tenantAdmin := testInstanceBuildUser(t, dbSession, uuid.New().String(), providerOrg, []string{authz.TenantAdminRole}) + otherOrgAdmin := testInstanceBuildUser(t, dbSession, uuid.New().String(), "some-other-org", []string{authz.ProviderAdminRole}) + + // pre-existing binding to exercise dup-url and dup-static-org rejection. + // JWKS URLs must be distinct across issuers (config-parity uniqueness); the + // stub serves the same empty key set for any path. + issDAO := cdbm.NewIssuerDAO(dbSession) + _, err := issDAO.Create(ctx, nil, cdbm.IssuerCreateInput{ + Name: "existing", IssuerURL: "https://idp.existing.com", JWKSURL: jwks.URL + "/existing", Origin: "custom", + ClaimMappings: []cdbm.IssuerClaimMapping{{OrgName: "tenant-existing", Roles: []string{"TENANT_ADMIN"}}}, + Status: cdbm.IssuerStatusPending, CreatedBy: providerAdmin.ID, + }) + require.Nil(t, err) + + body := func(name, org, issuerURL string) string { + return fmt.Sprintf(`{"name":%q,"issuerUrl":%q,"jwksUrl":%q,"claimMappings":[{"orgName":%q,"roles":["TENANT_ADMIN"]}]}`, name, issuerURL, jwks.URL+"/"+name, org) + } + + tests := []struct { + name string + reqOrgName string + user *cdbm.User + reqBody string + expectedStatus int + }{ + {"provider admin registers external issuer", providerOrg, providerAdmin, body("acme-idp", "tenant-acme", "https://idp.acme.com"), http.StatusCreated}, + {"tenant admin forbidden", providerOrg, tenantAdmin, body("x", "tenant-x", "https://idp.x.com"), http.StatusForbidden}, + {"non-member forbidden", providerOrg, otherOrgAdmin, body("y", "tenant-y", "https://idp.y.com"), http.StatusForbidden}, + {"invalid issuer url", providerOrg, providerAdmin, body("z", "tenant-z", "not-a-url"), http.StatusBadRequest}, + {"non-custom origin rejected", providerOrg, providerAdmin, fmt.Sprintf(`{"name":"kas-idp","origin":"kas-legacy","issuerUrl":"https://idp.kas.com","jwksUrl":%q,"claimMappings":[{"orgName":"tenant-kas","roles":["TENANT_ADMIN"]}]}`, jwks.URL+"/kas"), http.StatusBadRequest}, + {"top-level service account rejected", providerOrg, providerAdmin, fmt.Sprintf(`{"name":"svc-idp","issuerUrl":"https://idp.svc.com","jwksUrl":%q,"serviceAccount":true,"claimMappings":[{"orgName":"tenant-svc","roles":["TENANT_ADMIN"]}]}`, jwks.URL+"/svc"), http.StatusBadRequest}, + {"missing claim mappings", providerOrg, providerAdmin, fmt.Sprintf(`{"name":"empty-idp","issuerUrl":"https://idp.empty.com","jwksUrl":%q}`, jwks.URL+"/empty"), http.StatusBadRequest}, + {"missing roles", providerOrg, providerAdmin, fmt.Sprintf(`{"name":"n","issuerUrl":"https://idp.n.com","jwksUrl":%q,"claimMappings":[{"orgName":"tenant-n"}]}`, jwks.URL+"/n"), http.StatusBadRequest}, + {"invalid role", providerOrg, providerAdmin, fmt.Sprintf(`{"name":"badrole","issuerUrl":"https://idp.badrole.com","jwksUrl":%q,"claimMappings":[{"orgName":"tenant-badrole","roles":["NOT_A_ROLE"]}]}`, jwks.URL+"/badrole"), http.StatusBadRequest}, + {"duplicate issuer url", providerOrg, providerAdmin, body("dup", "tenant-other", "https://idp.existing.com"), http.StatusConflict}, + // duplicate static org is rejected unless allowDuplicateStaticOrgNames is set (config parity) + {"duplicate static org rejected", providerOrg, providerAdmin, body("dup-org", "tenant-existing", "https://idp.dup2.com"), http.StatusBadRequest}, + {"duplicate static org allowed with flag", providerOrg, providerAdmin, fmt.Sprintf(`{"name":"flagged","issuerUrl":"https://idp.flagged.com","jwksUrl":%q,"allowDuplicateStaticOrgNames":true,"claimMappings":[{"orgName":"tenant-existing","roles":["TENANT_ADMIN"]}]}`, jwks.URL+"/flagged"), http.StatusCreated}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.reqBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ec := e.NewContext(req, rec) + ec.SetParamNames("orgName") + ec.SetParamValues(tc.reqOrgName) + if tc.user != nil { + ec.Set("user", tc.user) + } + ec.SetRequest(ec.Request().WithContext(context.WithValue(ctx, otelecho.TracerKey, tracer))) + + err := NewCreateIssuerHandler(dbSession, cfg).Handle(ec) + assert.Nil(t, err) + assert.Equal(t, tc.expectedStatus, rec.Code) + + if tc.expectedStatus == http.StatusCreated { + rsp := &model.APIIssuer{} + require.Nil(t, json.Unmarshal(rec.Body.Bytes(), rsp)) + assert.NotEmpty(t, rsp.ID) + require.Len(t, rsp.ClaimMappings, 1) + // JWKS stub serves no keys, so the binding persists as Pending. + assert.Equal(t, cdbm.IssuerStatusPending, rsp.Status) + } + }) + } +} + +func TestIssuerHandler_Lifecycle(t *testing.T) { + ctx := context.Background() + dbSession := testInstanceInitDB(t) + defer dbSession.Close() + testIssuerSetupSchema(t, dbSession) + + cfg := common.GetTestConfig() + cfg.JwtOriginConfig = cauth.NewJWTOriginConfig() + tracer, _, ctx := common.TestCommonTraceProviderSetup(t, ctx) + jwks := testIssuerJWKSStub(t) + + providerOrg := "test-provider-org" + admin := testInstanceBuildUser(t, dbSession, uuid.New().String(), providerOrg, []string{authz.ProviderAdminRole}) + + newCtx := func() context.Context { return context.WithValue(ctx, otelecho.TracerKey, tracer) } + run := func(h interface{ Handle(echo.Context) error }, method, path, id, body string) *httptest.ResponseRecorder { + e := echo.New() + req := httptest.NewRequest(method, "/", strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ec := e.NewContext(req, rec) + if id != "" { + ec.SetParamNames("orgName", "id") + ec.SetParamValues(providerOrg, id) + } else { + ec.SetParamNames("orgName") + ec.SetParamValues(providerOrg) + } + ec.Set("user", admin) + ec.SetRequest(ec.Request().WithContext(newCtx())) + require.Nil(t, h.Handle(ec)) + return rec + } + + // Create + createBody := fmt.Sprintf(`{"name":"acme-idp","issuerUrl":"https://idp.acme.com","jwksUrl":%q,"claimMappings":[{"orgName":"tenant-acme","roles":["TENANT_ADMIN"]}]}`, jwks.URL) + rec := run(NewCreateIssuerHandler(dbSession, cfg), http.MethodPost, "/issuer", "", createBody) + require.Equal(t, http.StatusCreated, rec.Code) + created := &model.APIIssuer{} + require.Nil(t, json.Unmarshal(rec.Body.Bytes(), created)) + + // GetAll + rec = run(NewGetAllIssuerHandler(dbSession, cfg), http.MethodGet, "/issuer", "", "") + require.Equal(t, http.StatusOK, rec.Code) + var list []model.APIIssuer + require.Nil(t, json.Unmarshal(rec.Body.Bytes(), &list)) + assert.Len(t, list, 1) + + // Get by id + rec = run(NewGetIssuerHandler(dbSession, cfg), http.MethodGet, "/issuer/:id", created.ID, "") + assert.Equal(t, http.StatusOK, rec.Code) + + // Get unknown id -> 404 + rec = run(NewGetIssuerHandler(dbSession, cfg), http.MethodGet, "/issuer/:id", uuid.New().String(), "") + assert.Equal(t, http.StatusNotFound, rec.Code) + + // Get invalid id -> 400 + rec = run(NewGetIssuerHandler(dbSession, cfg), http.MethodGet, "/issuer/:id", "not-a-uuid", "") + assert.Equal(t, http.StatusBadRequest, rec.Code) + + // Update claim mappings + rec = run(NewUpdateIssuerHandler(dbSession, cfg), http.MethodPatch, "/issuer/:id", created.ID, `{"claimMappings":[{"orgName":"tenant-acme","roles":["TENANT_ADMIN","PROVIDER_ADMIN"]}]}`) + require.Equal(t, http.StatusOK, rec.Code) + updated := &model.APIIssuer{} + require.Nil(t, json.Unmarshal(rec.Body.Bytes(), updated)) + require.Len(t, updated.ClaimMappings, 1) + assert.Equal(t, []string{"TENANT_ADMIN", "PROVIDER_ADMIN"}, updated.ClaimMappings[0].Roles) + + // Immutable fields are rejected instead of being silently ignored by the JSON binder. + rec = run(NewUpdateIssuerHandler(dbSession, cfg), http.MethodPatch, "/issuer/:id", created.ID, `{"issuerUrl":"https://idp.other.com"}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + rec = run(NewUpdateIssuerHandler(dbSession, cfg), http.MethodPatch, "/issuer/:id", created.ID, `{"origin":"custom"}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + + // Update unknown id -> 404 + rec = run(NewUpdateIssuerHandler(dbSession, cfg), http.MethodPatch, "/issuer/:id", uuid.New().String(), `{}`) + assert.Equal(t, http.StatusNotFound, rec.Code) + + // Update invalid id -> 400 + rec = run(NewUpdateIssuerHandler(dbSession, cfg), http.MethodPatch, "/issuer/:id", "not-a-uuid", `{}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + + // Delete unknown id -> 404 + rec = run(NewDeleteIssuerHandler(dbSession, cfg), http.MethodDelete, "/issuer/:id", uuid.New().String(), "") + assert.Equal(t, http.StatusNotFound, rec.Code) + + // Delete invalid id -> 400 + rec = run(NewDeleteIssuerHandler(dbSession, cfg), http.MethodDelete, "/issuer/:id", "not-a-uuid", "") + assert.Equal(t, http.StatusBadRequest, rec.Code) + + // Delete + rec = run(NewDeleteIssuerHandler(dbSession, cfg), http.MethodDelete, "/issuer/:id", created.ID, "") + assert.Equal(t, http.StatusAccepted, rec.Code) + + // GetAll now empty + rec = run(NewGetAllIssuerHandler(dbSession, cfg), http.MethodGet, "/issuer", "", "") + require.Equal(t, http.StatusOK, rec.Code) + require.Nil(t, json.Unmarshal(rec.Body.Bytes(), &list)) + assert.Len(t, list, 0) +} + +// TestIssuerHandler_Authz asserts every issuer endpoint rejects a non-Provider-Admin caller. +func TestIssuerHandler_Authz(t *testing.T) { + ctx := context.Background() + dbSession := testInstanceInitDB(t) + defer dbSession.Close() + testIssuerSetupSchema(t, dbSession) + + cfg := common.GetTestConfig() + cfg.JwtOriginConfig = cauth.NewJWTOriginConfig() + tracer, _, ctx := common.TestCommonTraceProviderSetup(t, ctx) + + providerOrg := "test-provider-org" + tenantAdmin := testInstanceBuildUser(t, dbSession, uuid.New().String(), providerOrg, []string{authz.TenantAdminRole}) + + run := func(h interface{ Handle(echo.Context) error }, method, id, body string) int { + e := echo.New() + req := httptest.NewRequest(method, "/", strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + ec := e.NewContext(req, rec) + if id != "" { + ec.SetParamNames("orgName", "id") + ec.SetParamValues(providerOrg, id) + } else { + ec.SetParamNames("orgName") + ec.SetParamValues(providerOrg) + } + ec.Set("user", tenantAdmin) + ec.SetRequest(ec.Request().WithContext(context.WithValue(ctx, otelecho.TracerKey, tracer))) + require.Nil(t, h.Handle(ec)) + return rec.Code + } + + id := uuid.New().String() + assert.Equal(t, http.StatusForbidden, run(NewCreateIssuerHandler(dbSession, cfg), http.MethodPost, "", `{"name":"x","issuerUrl":"https://idp.x.com","jwksUrl":"https://idp.x.com/jwks"}`)) + assert.Equal(t, http.StatusForbidden, run(NewGetAllIssuerHandler(dbSession, cfg), http.MethodGet, "", "")) + assert.Equal(t, http.StatusForbidden, run(NewGetIssuerHandler(dbSession, cfg), http.MethodGet, id, "")) + assert.Equal(t, http.StatusForbidden, run(NewUpdateIssuerHandler(dbSession, cfg), http.MethodPatch, id, `{}`)) + assert.Equal(t, http.StatusForbidden, run(NewDeleteIssuerHandler(dbSession, cfg), http.MethodDelete, id, "")) +} diff --git a/rest-api/api/pkg/api/model/error.go b/rest-api/api/pkg/api/model/error.go index 65ccd4859c..357c6f7ebd 100644 --- a/rest-api/api/pkg/api/model/error.go +++ b/rest-api/api/pkg/api/model/error.go @@ -7,6 +7,8 @@ package model const ( validationErrorValueRequired = "a value is required" validationErrorInvalidUUID = "must be a valid UUID" + validationErrorInvalidURL = "must be a valid URL" + validationErrorInvalidRole = "must be a valid role" validationErrorStringLength = "must be at least 2 characters and maximum 256 characters" validationErrorDescriptionStringLength = "maximum 1024 characters are allowed in description" validationErrorMachineMaintenanceStringLength = "must be at least 5 characters and maximum 256 characters" diff --git a/rest-api/api/pkg/api/model/issuer.go b/rest-api/api/pkg/api/model/issuer.go new file mode 100644 index 0000000000..d4a42982f9 --- /dev/null +++ b/rest-api/api/pkg/api/model/issuer.go @@ -0,0 +1,222 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package model + +import ( + "errors" + "time" + + "github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model/util" + cauth "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/config" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + validation "github.com/go-ozzo/ozzo-validation/v4" + validationis "github.com/go-ozzo/ozzo-validation/v4/is" +) + +// APIClaimMapping is the API representation of an issuer claim mapping +type APIClaimMapping struct { + // OrgAttribute is the JWT claim path to extract org name (dynamic mapping) + OrgAttribute string `json:"orgAttribute,omitempty"` + // OrgDisplayAttribute is the JWT claim path for org display name (dynamic mapping) + OrgDisplayAttribute string `json:"orgDisplayAttribute,omitempty"` + // OrgName is the fixed organization name (static mapping) + OrgName string `json:"orgName,omitempty"` + // OrgDisplayName is the display name for a static org mapping + OrgDisplayName string `json:"orgDisplayName,omitempty"` + // RolesAttribute is the JWT claim path to extract roles (dynamic roles) + RolesAttribute string `json:"rolesAttribute,omitempty"` + // Roles is the static role list + Roles []string `json:"roles,omitempty"` + // IsServiceAccount assigns service-account roles + IsServiceAccount bool `json:"isServiceAccount,omitempty"` +} + +// validateClaimMappingRoles checks a mapping's static roles against the allowed set +func validateClaimMappingRoles(value any) error { + cm, ok := value.(APIClaimMapping) + if !ok { + return nil + } + for _, role := range cm.Roles { + if !cauth.IsValidRole(role) { + return errors.New(validationErrorInvalidRole) + } + } + return nil +} + +// APIIssuerCreateRequest is the data structure to capture a request to register an OIDC issuer binding +type APIIssuerCreateRequest struct { + // Name is a human-readable name for the issuer binding + Name string `json:"name"` + // IssuerURL is the expected "iss" claim value + IssuerURL string `json:"issuerUrl"` + // JWKSURL is the JWKS endpoint used to verify token signatures + JWKSURL string `json:"jwksUrl"` + // Origin is the token origin type (defaults to "custom" when empty) + Origin string `json:"origin"` + // ServiceAccount marks the issuer as a service-account issuer + ServiceAccount bool `json:"serviceAccount"` + // Audiences are the allowed audience values + Audiences []string `json:"audiences"` + // Scopes are the required scopes + Scopes []string `json:"scopes"` + // JWKSTimeout is the JWKS fetch timeout (e.g. "5s", "1m") + JWKSTimeout string `json:"jwksTimeout"` + // ClaimMappings map token claims to org and roles + ClaimMappings []APIClaimMapping `json:"claimMappings"` + // AllowDuplicateStaticOrgNames allows this issuer's static orgs to duplicate others + AllowDuplicateStaticOrgNames bool `json:"allowDuplicateStaticOrgNames"` +} + +// Validate ensures that the values passed in the request are acceptable +func (icr APIIssuerCreateRequest) Validate() error { + return validation.ValidateStruct(&icr, + validation.Field(&icr.Name, + validation.Required.Error(validationErrorStringLength), + validation.By(util.ValidateNameCharacters), + validation.Length(2, 256).Error(validationErrorStringLength)), + validation.Field(&icr.IssuerURL, + validation.Required.Error(validationErrorValueRequired), + validationis.URL.Error(validationErrorInvalidURL)), + validation.Field(&icr.JWKSURL, + validation.Required.Error(validationErrorValueRequired), + validationis.URL.Error(validationErrorInvalidURL)), + validation.Field(&icr.ClaimMappings, + validation.Each(validation.By(validateClaimMappingRoles))), + ) +} + +// APIIssuerUpdateRequest is the data structure to capture a request to update an issuer binding. +// IssuerURL and Origin are immutable and therefore not updatable. +type APIIssuerUpdateRequest struct { + // Name is a human-readable name for the issuer binding + Name *string `json:"name"` + // JWKSURL is the JWKS endpoint used to verify token signatures + JWKSURL *string `json:"jwksUrl"` + // ServiceAccount marks the issuer as a service-account issuer + ServiceAccount *bool `json:"serviceAccount"` + // Audiences are the allowed audience values + Audiences []string `json:"audiences"` + // Scopes are the required scopes + Scopes []string `json:"scopes"` + // JWKSTimeout is the JWKS fetch timeout (e.g. "5s", "1m") + JWKSTimeout *string `json:"jwksTimeout"` + // ClaimMappings map token claims to org and roles + ClaimMappings []APIClaimMapping `json:"claimMappings"` + // AllowDuplicateStaticOrgNames allows this issuer's static orgs to duplicate others + AllowDuplicateStaticOrgNames *bool `json:"allowDuplicateStaticOrgNames"` +} + +// Validate ensures that the values passed in the request are acceptable +func (iur APIIssuerUpdateRequest) Validate() error { + return validation.ValidateStruct(&iur, + validation.Field(&iur.Name, + validation.When(iur.Name != nil, validation.Required.Error(validationErrorStringLength)), + validation.When(iur.Name != nil, validation.By(util.ValidateNameCharacters)), + validation.When(iur.Name != nil, validation.Length(2, 256).Error(validationErrorStringLength))), + validation.Field(&iur.JWKSURL, + validation.When(iur.JWKSURL != nil, validationis.URL.Error(validationErrorInvalidURL))), + validation.Field(&iur.ClaimMappings, + validation.Each(validation.By(validateClaimMappingRoles))), + ) +} + +// APIIssuer is the API representation of a registered issuer binding +type APIIssuer struct { + // ID is the unique UUID v4 identifier for the Issuer + ID string `json:"id"` + // Name is the human-readable name for the issuer binding + Name string `json:"name"` + // IssuerURL is the expected "iss" claim value + IssuerURL string `json:"issuerUrl"` + // JWKSURL is the JWKS endpoint used to verify token signatures + JWKSURL string `json:"jwksUrl"` + // Origin is the token origin type + Origin string `json:"origin"` + // ServiceAccount marks the issuer as a service-account issuer + ServiceAccount bool `json:"serviceAccount"` + // Audiences are the allowed audience values + Audiences []string `json:"audiences,omitempty"` + // Scopes are the required scopes + Scopes []string `json:"scopes,omitempty"` + // JWKSTimeout is the JWKS fetch timeout + JWKSTimeout string `json:"jwksTimeout,omitempty"` + // ClaimMappings map token claims to org and roles + ClaimMappings []APIClaimMapping `json:"claimMappings,omitempty"` + // AllowDuplicateStaticOrgNames allows this issuer's static orgs to duplicate others + AllowDuplicateStaticOrgNames bool `json:"allowDuplicateStaticOrgNames"` + // Status indicates whether the issuer's JWKS has been fetched successfully + Status string `json:"status"` + // Created indicates the ISO datetime string for when the Issuer was created + Created time.Time `json:"created"` + // Updated indicates the ISO datetime string for when the Issuer was last updated + Updated time.Time `json:"updated"` +} + +// APIClaimMappings is a list of API claim mappings with DB conversions. +type APIClaimMappings []APIClaimMapping + +// ToDB converts the API claim mappings into the DB model shape. +func (in APIClaimMappings) ToDB() []cdbm.IssuerClaimMapping { + if in == nil { + return nil + } + out := make([]cdbm.IssuerClaimMapping, len(in)) + for i, cm := range in { + out[i] = cdbm.IssuerClaimMapping{ + OrgAttribute: cm.OrgAttribute, + OrgDisplayAttribute: cm.OrgDisplayAttribute, + OrgName: cm.OrgName, + OrgDisplayName: cm.OrgDisplayName, + RolesAttribute: cm.RolesAttribute, + Roles: cm.Roles, + IsServiceAccount: cm.IsServiceAccount, + } + } + return out +} + +// FromDB populates the API claim mappings from the DB model shape. +func (in *APIClaimMappings) FromDB(db []cdbm.IssuerClaimMapping) { + if db == nil { + *in = nil + return + } + out := make(APIClaimMappings, len(db)) + for i, cm := range db { + out[i] = APIClaimMapping{ + OrgAttribute: cm.OrgAttribute, + OrgDisplayAttribute: cm.OrgDisplayAttribute, + OrgName: cm.OrgName, + OrgDisplayName: cm.OrgDisplayName, + RolesAttribute: cm.RolesAttribute, + Roles: cm.Roles, + IsServiceAccount: cm.IsServiceAccount, + } + } + *in = out +} + +// NewAPIIssuer accepts a DB layer Issuer object and returns an API object +func NewAPIIssuer(iss *cdbm.Issuer) *APIIssuer { + var claimMappings APIClaimMappings + claimMappings.FromDB(iss.ClaimMappings) + return &APIIssuer{ + ID: iss.ID.String(), + Name: iss.Name, + IssuerURL: iss.IssuerURL, + JWKSURL: iss.JWKSURL, + Origin: iss.Origin, + ServiceAccount: iss.ServiceAccount, + Audiences: iss.Audiences, + Scopes: iss.Scopes, + JWKSTimeout: iss.JWKSTimeout, + ClaimMappings: claimMappings, + AllowDuplicateStaticOrgNames: iss.AllowDuplicateStaticOrgNames, + Status: iss.Status, + Created: iss.Created, + Updated: iss.Updated, + } +} diff --git a/rest-api/api/pkg/api/routes.go b/rest-api/api/pkg/api/routes.go index 3eb0667d63..d7a2cbec5b 100644 --- a/rest-api/api/pkg/api/routes.go +++ b/rest-api/api/pkg/api/routes.go @@ -737,6 +737,32 @@ func NewAPIRoutes(dbSession *cdb.Session, tc tClient.Client, tnc tClient.Namespa Method: http.MethodDelete, Handler: apiHandler.NewDeleteSSHKeyHandler(dbSession, tc, cfg), }, + // Issuer endpoints (dynamic OIDC issuer registration, Provider Admin only) + { + Path: apiPathPrefix + "/issuer", + Method: http.MethodPost, + Handler: apiHandler.NewCreateIssuerHandler(dbSession, cfg), + }, + { + Path: apiPathPrefix + "/issuer", + Method: http.MethodGet, + Handler: apiHandler.NewGetAllIssuerHandler(dbSession, cfg), + }, + { + Path: apiPathPrefix + "/issuer/:id", + Method: http.MethodGet, + Handler: apiHandler.NewGetIssuerHandler(dbSession, cfg), + }, + { + Path: apiPathPrefix + "/issuer/:id", + Method: http.MethodPatch, + Handler: apiHandler.NewUpdateIssuerHandler(dbSession, cfg), + }, + { + Path: apiPathPrefix + "/issuer/:id", + Method: http.MethodDelete, + Handler: apiHandler.NewDeleteIssuerHandler(dbSession, cfg), + }, // SSHKeyGroup endpoints { Path: apiPathPrefix + "/sshkeygroup", diff --git a/rest-api/openapi/spec.yaml b/rest-api/openapi/spec.yaml index a01aff4873..16791666da 100644 --- a/rest-api/openapi/spec.yaml +++ b/rest-api/openapi/spec.yaml @@ -97,6 +97,10 @@ tags: description: |- When the API service is configured in Service Account mode, API users can act as both Provider and Tenant. For service accounts, the Tenant entity is initialized as a privileged Tenant with `targetedInstanceCreation` capability enabled. + - name: Issuer + description: |- + Dynamic external OIDC issuer registrations. Provider Admins can register custom third-party IdPs at runtime by binding an issuer URL and JWKS URL to explicit claim mappings. + Dynamic registration only supports the `custom` issuer origin. KAS, SSA, and Keycloak issuers remain deployment-level configuration. - name: Infrastructure Provider description: |- Infrastructure Provider is the anchor entity for an organization that owns and manages Site resources. @@ -12465,6 +12469,176 @@ paths: name: reno-sre-access-v2 tags: - SSH Key + '/v2/org/{org}/nico/issuer': + parameters: + - schema: + type: string + name: org + in: path + required: true + description: Name of the Provider Org + get: + summary: Retrieve all registered OIDC issuers + operationId: get-all-issuer + tags: + - Issuer + description: |- + Retrieve all dynamically registered custom OIDC issuer bindings. + + Org must have an Infrastructure Provider entity. User must have authorization role with `PROVIDER_ADMIN` suffix. + responses: + '200': + description: Issuers retrieved + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Issuer' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/GenericHttpError' + post: + summary: Register an OIDC issuer + operationId: create-issuer + tags: + - Issuer + description: |- + Register a dynamic custom OIDC issuer binding. The issuer is persisted and installed into the live JWT origin map immediately. If the JWKS fetch fails, the issuer is returned with `Pending` status and will fail closed until a later JWKS refresh succeeds. + + Org must have an Infrastructure Provider entity. User must have authorization role with `PROVIDER_ADMIN` suffix. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IssuerCreateRequest' + examples: + Example 1: + value: + name: acme-sso + issuerUrl: https://login.acme.example.com + jwksUrl: https://login.acme.example.com/.well-known/jwks.json + audiences: + - nico-api + claimMappings: + - orgName: tenant-acme + orgDisplayName: ACME Tenant + roles: + - TENANT_ADMIN + responses: + '201': + description: Issuer registered + content: + application/json: + schema: + $ref: '#/components/schemas/Issuer' + '400': + $ref: '#/components/responses/ValidationError' + '403': + $ref: '#/components/responses/ForbiddenError' + '409': + $ref: '#/components/responses/ValidationError' + '500': + $ref: '#/components/responses/GenericHttpError' + '/v2/org/{org}/nico/issuer/{issuerId}': + parameters: + - schema: + type: string + name: org + in: path + required: true + description: Name of the Provider Org + - schema: + type: string + format: uuid + name: issuerId + in: path + required: true + description: ID of the Issuer + get: + summary: Retrieve a registered OIDC issuer + operationId: get-issuer + tags: + - Issuer + description: |- + Retrieve a dynamic custom OIDC issuer binding by ID. + + Org must have an Infrastructure Provider entity. User must have authorization role with `PROVIDER_ADMIN` suffix. + responses: + '200': + description: Issuer retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/Issuer' + '400': + $ref: '#/components/responses/ValidationError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '500': + $ref: '#/components/responses/GenericHttpError' + patch: + summary: Update a registered OIDC issuer + operationId: update-issuer + tags: + - Issuer + description: |- + Update a dynamic custom OIDC issuer binding. `issuerUrl` and `origin` are immutable. Updating JWKS URL or claim mappings replaces the live runtime config with the persisted row immediately; if JWKS fetch fails, the issuer is returned with `Pending` status and fails closed. + + Org must have an Infrastructure Provider entity. User must have authorization role with `PROVIDER_ADMIN` suffix. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IssuerUpdateRequest' + examples: + Example 1: + value: + jwksUrl: https://login.acme.example.com/rotated/jwks.json + claimMappings: + - orgName: tenant-acme + orgDisplayName: ACME Tenant + rolesAttribute: nico_roles + responses: + '200': + description: Issuer updated + content: + application/json: + schema: + $ref: '#/components/schemas/Issuer' + '400': + $ref: '#/components/responses/ValidationError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '500': + $ref: '#/components/responses/GenericHttpError' + delete: + summary: Delete a registered OIDC issuer + operationId: delete-issuer + tags: + - Issuer + description: |- + Soft-delete a dynamic custom OIDC issuer binding and remove it from the live JWT origin map. + + Org must have an Infrastructure Provider entity. User must have authorization role with `PROVIDER_ADMIN` suffix. + responses: + '202': + description: Accepted + '400': + $ref: '#/components/responses/ValidationError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '500': + $ref: '#/components/responses/GenericHttpError' '/v2/org/{org}/nico/user/current': parameters: - schema: @@ -13110,6 +13284,250 @@ components: macAddress: type: string description: BMC MAC address. Required for kind bmc-root, ignored for site-wide-root. + IssuerClaimMapping: + type: object + title: IssuerClaimMapping + description: |- + Mapping from custom issuer token claims to NICo organization and role data. + Each mapping must specify either `orgName` for a static organization or `orgAttribute` for a dynamic organization. Roles come from either the static `roles` list, `rolesAttribute`, or `isServiceAccount: true`. + properties: + orgAttribute: + type: string + description: JWT claim path to extract the organization name for dynamic mappings. + orgDisplayAttribute: + type: string + description: JWT claim path to extract the organization display name for dynamic mappings. + orgName: + type: string + description: Fixed organization name for static mappings. + orgDisplayName: + type: string + description: Display name for a static organization mapping. + rolesAttribute: + type: string + description: JWT claim path to extract role names. + roles: + type: array + description: Static NICo role names assigned for this mapping. + items: + type: string + enum: + - PROVIDER_ADMIN + - TENANT_ADMIN + isServiceAccount: + type: boolean + description: Whether this mapping authenticates tokens as service accounts. Only supported when the API runs in disconnected mode. + default: false + allOf: + - oneOf: + - required: + - orgName + - required: + - orgAttribute + - oneOf: + - required: + - roles + properties: + roles: + minItems: 1 + - required: + - rolesAttribute + - required: + - isServiceAccount + properties: + isServiceAccount: + enum: + - true + IssuerCreateRequest: + type: object + title: IssuerCreateRequest + description: Request to dynamically register a custom external OIDC issuer. + required: + - name + - issuerUrl + - jwksUrl + - claimMappings + properties: + name: + type: string + minLength: 2 + maxLength: 256 + description: Human-readable name for the issuer binding. + issuerUrl: + type: string + format: uri + description: Expected JWT `iss` claim value. Immutable after creation. + jwksUrl: + type: string + format: uri + description: JWKS endpoint used to verify token signatures. + origin: + type: string + enum: + - custom + default: custom + description: Dynamic registration supports only custom external issuer origins. Omit this field or set it to `custom`. + serviceAccount: + type: boolean + enum: + - false + default: false + description: Must be false for dynamic custom issuer registrations. Use `claimMappings[].isServiceAccount` for custom service-account mappings. + audiences: + type: array + description: Allowed token audiences. Token `aud` must contain at least one configured audience when this list is set. + items: + type: string + scopes: + type: array + description: Required token scopes. Token must contain all configured scopes when this list is set. + items: + type: string + jwksTimeout: + type: string + description: JWKS fetch timeout as a Go duration string, for example `5s` or `1m`. + claimMappings: + type: array + minItems: 1 + description: Claim mappings for this custom issuer. + items: + $ref: '#/components/schemas/IssuerClaimMapping' + allowDuplicateStaticOrgNames: + type: boolean + default: false + description: Allows this issuer's static `orgName` mappings to duplicate static org names from other issuers. + IssuerUpdateRequest: + type: object + title: IssuerUpdateRequest + description: Request to update a dynamic custom issuer. `issuerUrl` and `origin` are immutable. + properties: + name: + type: + - string + - 'null' + minLength: 2 + maxLength: 256 + description: Human-readable name for the issuer binding. + jwksUrl: + type: + - string + - 'null' + format: uri + description: JWKS endpoint used to verify token signatures. + serviceAccount: + type: + - boolean + - 'null' + enum: + - false + - null + description: Must be false for dynamic custom issuer registrations. Use `claimMappings[].isServiceAccount` for custom service-account mappings. + audiences: + type: array + description: Replacement allowed audience list. Omit the field to leave it unchanged. + items: + type: string + scopes: + type: array + description: Replacement required scope list. Omit the field to leave it unchanged. + items: + type: string + jwksTimeout: + type: + - string + - 'null' + description: JWKS fetch timeout as a Go duration string, for example `5s` or `1m`. + claimMappings: + type: array + description: Replacement claim mappings. Omit the field to leave mappings unchanged. + items: + $ref: '#/components/schemas/IssuerClaimMapping' + allowDuplicateStaticOrgNames: + type: + - boolean + - 'null' + description: Allows this issuer's static `orgName` mappings to duplicate static org names from other issuers. + IssuerStatus: + type: string + title: IssuerStatus + description: Whether the issuer's JWKS was reachable during the most recent apply or boot seed attempt. + enum: + - Pending + - Ready + Issuer: + type: object + title: Issuer + description: Dynamically registered custom OIDC issuer binding. + required: + - id + - name + - issuerUrl + - jwksUrl + - origin + - serviceAccount + - allowDuplicateStaticOrgNames + - status + - created + - updated + properties: + id: + type: string + format: uuid + description: Unique UUID v4 identifier for the issuer binding. + readOnly: true + name: + type: string + description: Human-readable name for the issuer binding. + issuerUrl: + type: string + format: uri + description: Expected JWT `iss` claim value. + jwksUrl: + type: string + format: uri + description: JWKS endpoint used to verify token signatures. + origin: + type: string + enum: + - custom + description: Dynamic issuer origin. Dynamic registration supports only `custom`. + serviceAccount: + type: boolean + description: Always false for dynamic custom issuer registrations; service-account behavior is configured per claim mapping. + audiences: + type: array + description: Allowed token audiences. + items: + type: string + scopes: + type: array + description: Required token scopes. + items: + type: string + jwksTimeout: + type: string + description: JWKS fetch timeout as a Go duration string. + claimMappings: + type: array + description: Claim mappings for this custom issuer. + items: + $ref: '#/components/schemas/IssuerClaimMapping' + allowDuplicateStaticOrgNames: + type: boolean + description: Allows this issuer's static `orgName` mappings to duplicate static org names from other issuers. + status: + $ref: '#/components/schemas/IssuerStatus' + description: Current JWKS reachability status. + created: + type: string + format: date-time + description: Date/time when the issuer binding was created. + readOnly: true + updated: + type: string + format: date-time + description: Date/time when the issuer binding was last updated. + readOnly: true InfrastructureProvider: description: Infrastructure providers own and manage datacenters type: object diff --git a/rest-api/sdk/standard/api_issuer.go b/rest-api/sdk/standard/api_issuer.go new file mode 100644 index 0000000000..ed688242eb --- /dev/null +++ b/rest-api/sdk/standard/api_issuer.go @@ -0,0 +1,771 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + "strings" +) + +// IssuerAPIService IssuerAPI service +type IssuerAPIService service + +type ApiCreateIssuerRequest struct { + ctx context.Context + ApiService *IssuerAPIService + org string + issuerCreateRequest *IssuerCreateRequest +} + +func (r ApiCreateIssuerRequest) IssuerCreateRequest(issuerCreateRequest IssuerCreateRequest) ApiCreateIssuerRequest { + r.issuerCreateRequest = &issuerCreateRequest + return r +} + +func (r ApiCreateIssuerRequest) Execute() (*Issuer, *http.Response, error) { + return r.ApiService.CreateIssuerExecute(r) +} + +/* +CreateIssuer Register an OIDC issuer + +Register a dynamic custom OIDC issuer binding. The issuer is persisted and installed into the live JWT origin map immediately. If the JWKS fetch fails, the issuer is returned with `Pending` status and will fail closed until a later JWKS refresh succeeds. + +Org must have an Infrastructure Provider entity. User must have authorization role with `PROVIDER_ADMIN` suffix. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param org Name of the Provider Org + @return ApiCreateIssuerRequest +*/ +func (a *IssuerAPIService) CreateIssuer(ctx context.Context, org string) ApiCreateIssuerRequest { + return ApiCreateIssuerRequest{ + ApiService: a, + ctx: ctx, + org: org, + } +} + +// Execute executes the request +// +// @return Issuer +func (a *IssuerAPIService) CreateIssuerExecute(r ApiCreateIssuerRequest) (*Issuer, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *Issuer + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "IssuerAPIService.CreateIssuer") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v2/org/{org}/nico/issuer" + localVarPath = strings.Replace(localVarPath, "{"+"org"+"}", url.PathEscape(parameterValueToString(r.org, "org")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.issuerCreateRequest == nil { + return localVarReturnValue, nil, reportError("issuerCreateRequest is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.issuerCreateRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 409 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiDeleteIssuerRequest struct { + ctx context.Context + ApiService *IssuerAPIService + org string + issuerId string +} + +func (r ApiDeleteIssuerRequest) Execute() (*http.Response, error) { + return r.ApiService.DeleteIssuerExecute(r) +} + +/* +DeleteIssuer Delete a registered OIDC issuer + +Soft-delete a dynamic custom OIDC issuer binding and remove it from the live JWT origin map. + +Org must have an Infrastructure Provider entity. User must have authorization role with `PROVIDER_ADMIN` suffix. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param org Name of the Provider Org + @param issuerId ID of the Issuer + @return ApiDeleteIssuerRequest +*/ +func (a *IssuerAPIService) DeleteIssuer(ctx context.Context, org string, issuerId string) ApiDeleteIssuerRequest { + return ApiDeleteIssuerRequest{ + ApiService: a, + ctx: ctx, + org: org, + issuerId: issuerId, + } +} + +// Execute executes the request +func (a *IssuerAPIService) DeleteIssuerExecute(r ApiDeleteIssuerRequest) (*http.Response, error) { + var ( + localVarHTTPMethod = http.MethodDelete + localVarPostBody interface{} + formFiles []formFile + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "IssuerAPIService.DeleteIssuer") + if err != nil { + return nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v2/org/{org}/nico/issuer/{issuerId}" + localVarPath = strings.Replace(localVarPath, "{"+"org"+"}", url.PathEscape(parameterValueToString(r.org, "org")), -1) + localVarPath = strings.Replace(localVarPath, "{"+"issuerId"+"}", url.PathEscape(parameterValueToString(r.issuerId, "issuerId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} + +type ApiGetAllIssuerRequest struct { + ctx context.Context + ApiService *IssuerAPIService + org string +} + +func (r ApiGetAllIssuerRequest) Execute() ([]Issuer, *http.Response, error) { + return r.ApiService.GetAllIssuerExecute(r) +} + +/* +GetAllIssuer Retrieve all registered OIDC issuers + +Retrieve all dynamically registered custom OIDC issuer bindings. + +Org must have an Infrastructure Provider entity. User must have authorization role with `PROVIDER_ADMIN` suffix. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param org Name of the Provider Org + @return ApiGetAllIssuerRequest +*/ +func (a *IssuerAPIService) GetAllIssuer(ctx context.Context, org string) ApiGetAllIssuerRequest { + return ApiGetAllIssuerRequest{ + ApiService: a, + ctx: ctx, + org: org, + } +} + +// Execute executes the request +// +// @return []Issuer +func (a *IssuerAPIService) GetAllIssuerExecute(r ApiGetAllIssuerRequest) ([]Issuer, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue []Issuer + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "IssuerAPIService.GetAllIssuer") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v2/org/{org}/nico/issuer" + localVarPath = strings.Replace(localVarPath, "{"+"org"+"}", url.PathEscape(parameterValueToString(r.org, "org")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 403 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiGetIssuerRequest struct { + ctx context.Context + ApiService *IssuerAPIService + org string + issuerId string +} + +func (r ApiGetIssuerRequest) Execute() (*Issuer, *http.Response, error) { + return r.ApiService.GetIssuerExecute(r) +} + +/* +GetIssuer Retrieve a registered OIDC issuer + +Retrieve a dynamic custom OIDC issuer binding by ID. + +Org must have an Infrastructure Provider entity. User must have authorization role with `PROVIDER_ADMIN` suffix. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param org Name of the Provider Org + @param issuerId ID of the Issuer + @return ApiGetIssuerRequest +*/ +func (a *IssuerAPIService) GetIssuer(ctx context.Context, org string, issuerId string) ApiGetIssuerRequest { + return ApiGetIssuerRequest{ + ApiService: a, + ctx: ctx, + org: org, + issuerId: issuerId, + } +} + +// Execute executes the request +// +// @return Issuer +func (a *IssuerAPIService) GetIssuerExecute(r ApiGetIssuerRequest) (*Issuer, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *Issuer + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "IssuerAPIService.GetIssuer") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v2/org/{org}/nico/issuer/{issuerId}" + localVarPath = strings.Replace(localVarPath, "{"+"org"+"}", url.PathEscape(parameterValueToString(r.org, "org")), -1) + localVarPath = strings.Replace(localVarPath, "{"+"issuerId"+"}", url.PathEscape(parameterValueToString(r.issuerId, "issuerId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiUpdateIssuerRequest struct { + ctx context.Context + ApiService *IssuerAPIService + org string + issuerId string + issuerUpdateRequest *IssuerUpdateRequest +} + +func (r ApiUpdateIssuerRequest) IssuerUpdateRequest(issuerUpdateRequest IssuerUpdateRequest) ApiUpdateIssuerRequest { + r.issuerUpdateRequest = &issuerUpdateRequest + return r +} + +func (r ApiUpdateIssuerRequest) Execute() (*Issuer, *http.Response, error) { + return r.ApiService.UpdateIssuerExecute(r) +} + +/* +UpdateIssuer Update a registered OIDC issuer + +Update a dynamic custom OIDC issuer binding. `issuerUrl` and `origin` are immutable. Updating JWKS URL or claim mappings replaces the live runtime config with the persisted row immediately; if JWKS fetch fails, the issuer is returned with `Pending` status and fails closed. + +Org must have an Infrastructure Provider entity. User must have authorization role with `PROVIDER_ADMIN` suffix. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param org Name of the Provider Org + @param issuerId ID of the Issuer + @return ApiUpdateIssuerRequest +*/ +func (a *IssuerAPIService) UpdateIssuer(ctx context.Context, org string, issuerId string) ApiUpdateIssuerRequest { + return ApiUpdateIssuerRequest{ + ApiService: a, + ctx: ctx, + org: org, + issuerId: issuerId, + } +} + +// Execute executes the request +// +// @return Issuer +func (a *IssuerAPIService) UpdateIssuerExecute(r ApiUpdateIssuerRequest) (*Issuer, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPatch + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *Issuer + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "IssuerAPIService.UpdateIssuer") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v2/org/{org}/nico/issuer/{issuerId}" + localVarPath = strings.Replace(localVarPath, "{"+"org"+"}", url.PathEscape(parameterValueToString(r.org, "org")), -1) + localVarPath = strings.Replace(localVarPath, "{"+"issuerId"+"}", url.PathEscape(parameterValueToString(r.issuerId, "issuerId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.issuerUpdateRequest == nil { + return localVarReturnValue, nil, reportError("issuerUpdateRequest is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.issuerUpdateRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 403 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v NICoAPIError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/rest-api/sdk/standard/client.go b/rest-api/sdk/standard/client.go index d631a2b8a9..a91016a1a6 100644 --- a/rest-api/sdk/standard/client.go +++ b/rest-api/sdk/standard/client.go @@ -77,6 +77,8 @@ type APIClient struct { InstanceTypeAPI *InstanceTypeAPIService + IssuerAPI *IssuerAPIService + MachineAPI *MachineAPIService MetadataAPI *MetadataAPIService @@ -151,6 +153,7 @@ func NewAPIClient(cfg *Configuration) *APIClient { c.InfrastructureProviderAPI = (*InfrastructureProviderAPIService)(&c.common) c.InstanceAPI = (*InstanceAPIService)(&c.common) c.InstanceTypeAPI = (*InstanceTypeAPIService)(&c.common) + c.IssuerAPI = (*IssuerAPIService)(&c.common) c.MachineAPI = (*MachineAPIService)(&c.common) c.MetadataAPI = (*MetadataAPIService)(&c.common) c.NVLinkLogicalPartitionAPI = (*NVLinkLogicalPartitionAPIService)(&c.common) diff --git a/rest-api/sdk/standard/model_bmc_credential.go b/rest-api/sdk/standard/model_bmc_credential.go index 997b722b8d..3ba1a9f6f7 100644 --- a/rest-api/sdk/standard/model_bmc_credential.go +++ b/rest-api/sdk/standard/model_bmc_credential.go @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + /* NVIDIA Infra Controller REST API diff --git a/rest-api/sdk/standard/model_deletion_accepted_response.go b/rest-api/sdk/standard/model_deletion_accepted_response.go index e6c0dcaa35..162b467be6 100644 --- a/rest-api/sdk/standard/model_deletion_accepted_response.go +++ b/rest-api/sdk/standard/model_deletion_accepted_response.go @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + /* NVIDIA Infra Controller REST API diff --git a/rest-api/sdk/standard/model_issuer.go b/rest-api/sdk/standard/model_issuer.go new file mode 100644 index 0000000000..b944ae9988 --- /dev/null +++ b/rest-api/sdk/standard/model_issuer.go @@ -0,0 +1,570 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// checks if the Issuer type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &Issuer{} + +// Issuer Dynamically registered custom OIDC issuer binding. +type Issuer struct { + // Unique UUID v4 identifier for the issuer binding. + Id string `json:"id"` + // Human-readable name for the issuer binding. + Name string `json:"name"` + // Expected JWT `iss` claim value. + IssuerUrl string `json:"issuerUrl"` + // JWKS endpoint used to verify token signatures. + JwksUrl string `json:"jwksUrl"` + // Dynamic issuer origin. Dynamic registration supports only `custom`. + Origin string `json:"origin"` + // Always false for dynamic custom issuer registrations; service-account behavior is configured per claim mapping. + ServiceAccount bool `json:"serviceAccount"` + // Allowed token audiences. + Audiences []string `json:"audiences,omitempty"` + // Required token scopes. + Scopes []string `json:"scopes,omitempty"` + // JWKS fetch timeout as a Go duration string. + JwksTimeout *string `json:"jwksTimeout,omitempty"` + // Claim mappings for this custom issuer. + ClaimMappings []IssuerClaimMapping `json:"claimMappings,omitempty"` + // Allows this issuer's static `orgName` mappings to duplicate static org names from other issuers. + AllowDuplicateStaticOrgNames bool `json:"allowDuplicateStaticOrgNames"` + // Current JWKS reachability status. + Status IssuerStatus `json:"status"` + // Date/time when the issuer binding was created. + Created time.Time `json:"created"` + // Date/time when the issuer binding was last updated. + Updated time.Time `json:"updated"` +} + +type _Issuer Issuer + +// NewIssuer instantiates a new Issuer object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewIssuer(id string, name string, issuerUrl string, jwksUrl string, origin string, serviceAccount bool, allowDuplicateStaticOrgNames bool, status IssuerStatus, created time.Time, updated time.Time) *Issuer { + this := Issuer{} + this.Id = id + this.Name = name + this.IssuerUrl = issuerUrl + this.JwksUrl = jwksUrl + this.Origin = origin + this.ServiceAccount = serviceAccount + this.AllowDuplicateStaticOrgNames = allowDuplicateStaticOrgNames + this.Status = status + this.Created = created + this.Updated = updated + return &this +} + +// NewIssuerWithDefaults instantiates a new Issuer object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewIssuerWithDefaults() *Issuer { + this := Issuer{} + return &this +} + +// GetId returns the Id field value +func (o *Issuer) GetId() string { + if o == nil { + var ret string + return ret + } + + return o.Id +} + +// GetIdOk returns a tuple with the Id field value +// and a boolean to check if the value has been set. +func (o *Issuer) GetIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Id, true +} + +// SetId sets field value +func (o *Issuer) SetId(v string) { + o.Id = v +} + +// GetName returns the Name field value +func (o *Issuer) GetName() string { + if o == nil { + var ret string + return ret + } + + return o.Name +} + +// GetNameOk returns a tuple with the Name field value +// and a boolean to check if the value has been set. +func (o *Issuer) GetNameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Name, true +} + +// SetName sets field value +func (o *Issuer) SetName(v string) { + o.Name = v +} + +// GetIssuerUrl returns the IssuerUrl field value +func (o *Issuer) GetIssuerUrl() string { + if o == nil { + var ret string + return ret + } + + return o.IssuerUrl +} + +// GetIssuerUrlOk returns a tuple with the IssuerUrl field value +// and a boolean to check if the value has been set. +func (o *Issuer) GetIssuerUrlOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.IssuerUrl, true +} + +// SetIssuerUrl sets field value +func (o *Issuer) SetIssuerUrl(v string) { + o.IssuerUrl = v +} + +// GetJwksUrl returns the JwksUrl field value +func (o *Issuer) GetJwksUrl() string { + if o == nil { + var ret string + return ret + } + + return o.JwksUrl +} + +// GetJwksUrlOk returns a tuple with the JwksUrl field value +// and a boolean to check if the value has been set. +func (o *Issuer) GetJwksUrlOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.JwksUrl, true +} + +// SetJwksUrl sets field value +func (o *Issuer) SetJwksUrl(v string) { + o.JwksUrl = v +} + +// GetOrigin returns the Origin field value +func (o *Issuer) GetOrigin() string { + if o == nil { + var ret string + return ret + } + + return o.Origin +} + +// GetOriginOk returns a tuple with the Origin field value +// and a boolean to check if the value has been set. +func (o *Issuer) GetOriginOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Origin, true +} + +// SetOrigin sets field value +func (o *Issuer) SetOrigin(v string) { + o.Origin = v +} + +// GetServiceAccount returns the ServiceAccount field value +func (o *Issuer) GetServiceAccount() bool { + if o == nil { + var ret bool + return ret + } + + return o.ServiceAccount +} + +// GetServiceAccountOk returns a tuple with the ServiceAccount field value +// and a boolean to check if the value has been set. +func (o *Issuer) GetServiceAccountOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.ServiceAccount, true +} + +// SetServiceAccount sets field value +func (o *Issuer) SetServiceAccount(v bool) { + o.ServiceAccount = v +} + +// GetAudiences returns the Audiences field value if set, zero value otherwise. +func (o *Issuer) GetAudiences() []string { + if o == nil || IsNil(o.Audiences) { + var ret []string + return ret + } + return o.Audiences +} + +// GetAudiencesOk returns a tuple with the Audiences field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Issuer) GetAudiencesOk() ([]string, bool) { + if o == nil || IsNil(o.Audiences) { + return nil, false + } + return o.Audiences, true +} + +// HasAudiences returns a boolean if a field has been set. +func (o *Issuer) HasAudiences() bool { + if o != nil && !IsNil(o.Audiences) { + return true + } + + return false +} + +// SetAudiences gets a reference to the given []string and assigns it to the Audiences field. +func (o *Issuer) SetAudiences(v []string) { + o.Audiences = v +} + +// GetScopes returns the Scopes field value if set, zero value otherwise. +func (o *Issuer) GetScopes() []string { + if o == nil || IsNil(o.Scopes) { + var ret []string + return ret + } + return o.Scopes +} + +// GetScopesOk returns a tuple with the Scopes field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Issuer) GetScopesOk() ([]string, bool) { + if o == nil || IsNil(o.Scopes) { + return nil, false + } + return o.Scopes, true +} + +// HasScopes returns a boolean if a field has been set. +func (o *Issuer) HasScopes() bool { + if o != nil && !IsNil(o.Scopes) { + return true + } + + return false +} + +// SetScopes gets a reference to the given []string and assigns it to the Scopes field. +func (o *Issuer) SetScopes(v []string) { + o.Scopes = v +} + +// GetJwksTimeout returns the JwksTimeout field value if set, zero value otherwise. +func (o *Issuer) GetJwksTimeout() string { + if o == nil || IsNil(o.JwksTimeout) { + var ret string + return ret + } + return *o.JwksTimeout +} + +// GetJwksTimeoutOk returns a tuple with the JwksTimeout field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Issuer) GetJwksTimeoutOk() (*string, bool) { + if o == nil || IsNil(o.JwksTimeout) { + return nil, false + } + return o.JwksTimeout, true +} + +// HasJwksTimeout returns a boolean if a field has been set. +func (o *Issuer) HasJwksTimeout() bool { + if o != nil && !IsNil(o.JwksTimeout) { + return true + } + + return false +} + +// SetJwksTimeout gets a reference to the given string and assigns it to the JwksTimeout field. +func (o *Issuer) SetJwksTimeout(v string) { + o.JwksTimeout = &v +} + +// GetClaimMappings returns the ClaimMappings field value if set, zero value otherwise. +func (o *Issuer) GetClaimMappings() []IssuerClaimMapping { + if o == nil || IsNil(o.ClaimMappings) { + var ret []IssuerClaimMapping + return ret + } + return o.ClaimMappings +} + +// GetClaimMappingsOk returns a tuple with the ClaimMappings field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *Issuer) GetClaimMappingsOk() ([]IssuerClaimMapping, bool) { + if o == nil || IsNil(o.ClaimMappings) { + return nil, false + } + return o.ClaimMappings, true +} + +// HasClaimMappings returns a boolean if a field has been set. +func (o *Issuer) HasClaimMappings() bool { + if o != nil && !IsNil(o.ClaimMappings) { + return true + } + + return false +} + +// SetClaimMappings gets a reference to the given []IssuerClaimMapping and assigns it to the ClaimMappings field. +func (o *Issuer) SetClaimMappings(v []IssuerClaimMapping) { + o.ClaimMappings = v +} + +// GetAllowDuplicateStaticOrgNames returns the AllowDuplicateStaticOrgNames field value +func (o *Issuer) GetAllowDuplicateStaticOrgNames() bool { + if o == nil { + var ret bool + return ret + } + + return o.AllowDuplicateStaticOrgNames +} + +// GetAllowDuplicateStaticOrgNamesOk returns a tuple with the AllowDuplicateStaticOrgNames field value +// and a boolean to check if the value has been set. +func (o *Issuer) GetAllowDuplicateStaticOrgNamesOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.AllowDuplicateStaticOrgNames, true +} + +// SetAllowDuplicateStaticOrgNames sets field value +func (o *Issuer) SetAllowDuplicateStaticOrgNames(v bool) { + o.AllowDuplicateStaticOrgNames = v +} + +// GetStatus returns the Status field value +func (o *Issuer) GetStatus() IssuerStatus { + if o == nil { + var ret IssuerStatus + return ret + } + + return o.Status +} + +// GetStatusOk returns a tuple with the Status field value +// and a boolean to check if the value has been set. +func (o *Issuer) GetStatusOk() (*IssuerStatus, bool) { + if o == nil { + return nil, false + } + return &o.Status, true +} + +// SetStatus sets field value +func (o *Issuer) SetStatus(v IssuerStatus) { + o.Status = v +} + +// GetCreated returns the Created field value +func (o *Issuer) GetCreated() time.Time { + if o == nil { + var ret time.Time + return ret + } + + return o.Created +} + +// GetCreatedOk returns a tuple with the Created field value +// and a boolean to check if the value has been set. +func (o *Issuer) GetCreatedOk() (*time.Time, bool) { + if o == nil { + return nil, false + } + return &o.Created, true +} + +// SetCreated sets field value +func (o *Issuer) SetCreated(v time.Time) { + o.Created = v +} + +// GetUpdated returns the Updated field value +func (o *Issuer) GetUpdated() time.Time { + if o == nil { + var ret time.Time + return ret + } + + return o.Updated +} + +// GetUpdatedOk returns a tuple with the Updated field value +// and a boolean to check if the value has been set. +func (o *Issuer) GetUpdatedOk() (*time.Time, bool) { + if o == nil { + return nil, false + } + return &o.Updated, true +} + +// SetUpdated sets field value +func (o *Issuer) SetUpdated(v time.Time) { + o.Updated = v +} + +func (o Issuer) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o Issuer) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["id"] = o.Id + toSerialize["name"] = o.Name + toSerialize["issuerUrl"] = o.IssuerUrl + toSerialize["jwksUrl"] = o.JwksUrl + toSerialize["origin"] = o.Origin + toSerialize["serviceAccount"] = o.ServiceAccount + if !IsNil(o.Audiences) { + toSerialize["audiences"] = o.Audiences + } + if !IsNil(o.Scopes) { + toSerialize["scopes"] = o.Scopes + } + if !IsNil(o.JwksTimeout) { + toSerialize["jwksTimeout"] = o.JwksTimeout + } + if !IsNil(o.ClaimMappings) { + toSerialize["claimMappings"] = o.ClaimMappings + } + toSerialize["allowDuplicateStaticOrgNames"] = o.AllowDuplicateStaticOrgNames + toSerialize["status"] = o.Status + toSerialize["created"] = o.Created + toSerialize["updated"] = o.Updated + return toSerialize, nil +} + +func (o *Issuer) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "id", + "name", + "issuerUrl", + "jwksUrl", + "origin", + "serviceAccount", + "allowDuplicateStaticOrgNames", + "status", + "created", + "updated", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varIssuer := _Issuer{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varIssuer) + + if err != nil { + return err + } + + *o = Issuer(varIssuer) + + return err +} + +type NullableIssuer struct { + value *Issuer + isSet bool +} + +func (v NullableIssuer) Get() *Issuer { + return v.value +} + +func (v *NullableIssuer) Set(val *Issuer) { + v.value = val + v.isSet = true +} + +func (v NullableIssuer) IsSet() bool { + return v.isSet +} + +func (v *NullableIssuer) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableIssuer(val *Issuer) *NullableIssuer { + return &NullableIssuer{value: val, isSet: true} +} + +func (v NullableIssuer) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableIssuer) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_issuer_claim_mapping.go b/rest-api/sdk/standard/model_issuer_claim_mapping.go new file mode 100644 index 0000000000..64e8699bf4 --- /dev/null +++ b/rest-api/sdk/standard/model_issuer_claim_mapping.go @@ -0,0 +1,354 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" +) + +// checks if the IssuerClaimMapping type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &IssuerClaimMapping{} + +// IssuerClaimMapping Mapping from custom issuer token claims to NICo organization and role data. Each mapping must specify either `orgName` for a static organization or `orgAttribute` for a dynamic organization. Roles come from either the static `roles` list, `rolesAttribute`, or `isServiceAccount: true`. +type IssuerClaimMapping struct { + // JWT claim path to extract the organization name for dynamic mappings. + OrgAttribute *string `json:"orgAttribute,omitempty"` + // JWT claim path to extract the organization display name for dynamic mappings. + OrgDisplayAttribute *string `json:"orgDisplayAttribute,omitempty"` + // Fixed organization name for static mappings. + OrgName *string `json:"orgName,omitempty"` + // Display name for a static organization mapping. + OrgDisplayName *string `json:"orgDisplayName,omitempty"` + // JWT claim path to extract role names. + RolesAttribute *string `json:"rolesAttribute,omitempty"` + // Static NICo role names assigned for this mapping. + Roles []string `json:"roles,omitempty"` + // Whether this mapping authenticates tokens as service accounts. Only supported when the API runs in disconnected mode. + IsServiceAccount *bool `json:"isServiceAccount,omitempty"` +} + +// NewIssuerClaimMapping instantiates a new IssuerClaimMapping object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewIssuerClaimMapping() *IssuerClaimMapping { + this := IssuerClaimMapping{} + var isServiceAccount bool = false + this.IsServiceAccount = &isServiceAccount + return &this +} + +// NewIssuerClaimMappingWithDefaults instantiates a new IssuerClaimMapping object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewIssuerClaimMappingWithDefaults() *IssuerClaimMapping { + this := IssuerClaimMapping{} + var isServiceAccount bool = false + this.IsServiceAccount = &isServiceAccount + return &this +} + +// GetOrgAttribute returns the OrgAttribute field value if set, zero value otherwise. +func (o *IssuerClaimMapping) GetOrgAttribute() string { + if o == nil || IsNil(o.OrgAttribute) { + var ret string + return ret + } + return *o.OrgAttribute +} + +// GetOrgAttributeOk returns a tuple with the OrgAttribute field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerClaimMapping) GetOrgAttributeOk() (*string, bool) { + if o == nil || IsNil(o.OrgAttribute) { + return nil, false + } + return o.OrgAttribute, true +} + +// HasOrgAttribute returns a boolean if a field has been set. +func (o *IssuerClaimMapping) HasOrgAttribute() bool { + if o != nil && !IsNil(o.OrgAttribute) { + return true + } + + return false +} + +// SetOrgAttribute gets a reference to the given string and assigns it to the OrgAttribute field. +func (o *IssuerClaimMapping) SetOrgAttribute(v string) { + o.OrgAttribute = &v +} + +// GetOrgDisplayAttribute returns the OrgDisplayAttribute field value if set, zero value otherwise. +func (o *IssuerClaimMapping) GetOrgDisplayAttribute() string { + if o == nil || IsNil(o.OrgDisplayAttribute) { + var ret string + return ret + } + return *o.OrgDisplayAttribute +} + +// GetOrgDisplayAttributeOk returns a tuple with the OrgDisplayAttribute field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerClaimMapping) GetOrgDisplayAttributeOk() (*string, bool) { + if o == nil || IsNil(o.OrgDisplayAttribute) { + return nil, false + } + return o.OrgDisplayAttribute, true +} + +// HasOrgDisplayAttribute returns a boolean if a field has been set. +func (o *IssuerClaimMapping) HasOrgDisplayAttribute() bool { + if o != nil && !IsNil(o.OrgDisplayAttribute) { + return true + } + + return false +} + +// SetOrgDisplayAttribute gets a reference to the given string and assigns it to the OrgDisplayAttribute field. +func (o *IssuerClaimMapping) SetOrgDisplayAttribute(v string) { + o.OrgDisplayAttribute = &v +} + +// GetOrgName returns the OrgName field value if set, zero value otherwise. +func (o *IssuerClaimMapping) GetOrgName() string { + if o == nil || IsNil(o.OrgName) { + var ret string + return ret + } + return *o.OrgName +} + +// GetOrgNameOk returns a tuple with the OrgName field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerClaimMapping) GetOrgNameOk() (*string, bool) { + if o == nil || IsNil(o.OrgName) { + return nil, false + } + return o.OrgName, true +} + +// HasOrgName returns a boolean if a field has been set. +func (o *IssuerClaimMapping) HasOrgName() bool { + if o != nil && !IsNil(o.OrgName) { + return true + } + + return false +} + +// SetOrgName gets a reference to the given string and assigns it to the OrgName field. +func (o *IssuerClaimMapping) SetOrgName(v string) { + o.OrgName = &v +} + +// GetOrgDisplayName returns the OrgDisplayName field value if set, zero value otherwise. +func (o *IssuerClaimMapping) GetOrgDisplayName() string { + if o == nil || IsNil(o.OrgDisplayName) { + var ret string + return ret + } + return *o.OrgDisplayName +} + +// GetOrgDisplayNameOk returns a tuple with the OrgDisplayName field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerClaimMapping) GetOrgDisplayNameOk() (*string, bool) { + if o == nil || IsNil(o.OrgDisplayName) { + return nil, false + } + return o.OrgDisplayName, true +} + +// HasOrgDisplayName returns a boolean if a field has been set. +func (o *IssuerClaimMapping) HasOrgDisplayName() bool { + if o != nil && !IsNil(o.OrgDisplayName) { + return true + } + + return false +} + +// SetOrgDisplayName gets a reference to the given string and assigns it to the OrgDisplayName field. +func (o *IssuerClaimMapping) SetOrgDisplayName(v string) { + o.OrgDisplayName = &v +} + +// GetRolesAttribute returns the RolesAttribute field value if set, zero value otherwise. +func (o *IssuerClaimMapping) GetRolesAttribute() string { + if o == nil || IsNil(o.RolesAttribute) { + var ret string + return ret + } + return *o.RolesAttribute +} + +// GetRolesAttributeOk returns a tuple with the RolesAttribute field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerClaimMapping) GetRolesAttributeOk() (*string, bool) { + if o == nil || IsNil(o.RolesAttribute) { + return nil, false + } + return o.RolesAttribute, true +} + +// HasRolesAttribute returns a boolean if a field has been set. +func (o *IssuerClaimMapping) HasRolesAttribute() bool { + if o != nil && !IsNil(o.RolesAttribute) { + return true + } + + return false +} + +// SetRolesAttribute gets a reference to the given string and assigns it to the RolesAttribute field. +func (o *IssuerClaimMapping) SetRolesAttribute(v string) { + o.RolesAttribute = &v +} + +// GetRoles returns the Roles field value if set, zero value otherwise. +func (o *IssuerClaimMapping) GetRoles() []string { + if o == nil || IsNil(o.Roles) { + var ret []string + return ret + } + return o.Roles +} + +// GetRolesOk returns a tuple with the Roles field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerClaimMapping) GetRolesOk() ([]string, bool) { + if o == nil || IsNil(o.Roles) { + return nil, false + } + return o.Roles, true +} + +// HasRoles returns a boolean if a field has been set. +func (o *IssuerClaimMapping) HasRoles() bool { + if o != nil && !IsNil(o.Roles) { + return true + } + + return false +} + +// SetRoles gets a reference to the given []string and assigns it to the Roles field. +func (o *IssuerClaimMapping) SetRoles(v []string) { + o.Roles = v +} + +// GetIsServiceAccount returns the IsServiceAccount field value if set, zero value otherwise. +func (o *IssuerClaimMapping) GetIsServiceAccount() bool { + if o == nil || IsNil(o.IsServiceAccount) { + var ret bool + return ret + } + return *o.IsServiceAccount +} + +// GetIsServiceAccountOk returns a tuple with the IsServiceAccount field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerClaimMapping) GetIsServiceAccountOk() (*bool, bool) { + if o == nil || IsNil(o.IsServiceAccount) { + return nil, false + } + return o.IsServiceAccount, true +} + +// HasIsServiceAccount returns a boolean if a field has been set. +func (o *IssuerClaimMapping) HasIsServiceAccount() bool { + if o != nil && !IsNil(o.IsServiceAccount) { + return true + } + + return false +} + +// SetIsServiceAccount gets a reference to the given bool and assigns it to the IsServiceAccount field. +func (o *IssuerClaimMapping) SetIsServiceAccount(v bool) { + o.IsServiceAccount = &v +} + +func (o IssuerClaimMapping) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o IssuerClaimMapping) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.OrgAttribute) { + toSerialize["orgAttribute"] = o.OrgAttribute + } + if !IsNil(o.OrgDisplayAttribute) { + toSerialize["orgDisplayAttribute"] = o.OrgDisplayAttribute + } + if !IsNil(o.OrgName) { + toSerialize["orgName"] = o.OrgName + } + if !IsNil(o.OrgDisplayName) { + toSerialize["orgDisplayName"] = o.OrgDisplayName + } + if !IsNil(o.RolesAttribute) { + toSerialize["rolesAttribute"] = o.RolesAttribute + } + if !IsNil(o.Roles) { + toSerialize["roles"] = o.Roles + } + if !IsNil(o.IsServiceAccount) { + toSerialize["isServiceAccount"] = o.IsServiceAccount + } + return toSerialize, nil +} + +type NullableIssuerClaimMapping struct { + value *IssuerClaimMapping + isSet bool +} + +func (v NullableIssuerClaimMapping) Get() *IssuerClaimMapping { + return v.value +} + +func (v *NullableIssuerClaimMapping) Set(val *IssuerClaimMapping) { + v.value = val + v.isSet = true +} + +func (v NullableIssuerClaimMapping) IsSet() bool { + return v.isSet +} + +func (v *NullableIssuerClaimMapping) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableIssuerClaimMapping(val *IssuerClaimMapping) *NullableIssuerClaimMapping { + return &NullableIssuerClaimMapping{value: val, isSet: true} +} + +func (v NullableIssuerClaimMapping) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableIssuerClaimMapping) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_issuer_create_request.go b/rest-api/sdk/standard/model_issuer_create_request.go new file mode 100644 index 0000000000..ae6c6779f0 --- /dev/null +++ b/rest-api/sdk/standard/model_issuer_create_request.go @@ -0,0 +1,481 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// checks if the IssuerCreateRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &IssuerCreateRequest{} + +// IssuerCreateRequest Request to dynamically register a custom external OIDC issuer. +type IssuerCreateRequest struct { + // Human-readable name for the issuer binding. + Name string `json:"name"` + // Expected JWT `iss` claim value. Immutable after creation. + IssuerUrl string `json:"issuerUrl"` + // JWKS endpoint used to verify token signatures. + JwksUrl string `json:"jwksUrl"` + // Dynamic registration supports only custom external issuer origins. Omit this field or set it to `custom`. + Origin *string `json:"origin,omitempty"` + // Must be false for dynamic custom issuer registrations. Use `claimMappings[].isServiceAccount` for custom service-account mappings. + ServiceAccount *bool `json:"serviceAccount,omitempty"` + // Allowed token audiences. Token `aud` must contain at least one configured audience when this list is set. + Audiences []string `json:"audiences,omitempty"` + // Required token scopes. Token must contain all configured scopes when this list is set. + Scopes []string `json:"scopes,omitempty"` + // JWKS fetch timeout as a Go duration string, for example `5s` or `1m`. + JwksTimeout *string `json:"jwksTimeout,omitempty"` + // Claim mappings for this custom issuer. + ClaimMappings []IssuerClaimMapping `json:"claimMappings"` + // Allows this issuer's static `orgName` mappings to duplicate static org names from other issuers. + AllowDuplicateStaticOrgNames *bool `json:"allowDuplicateStaticOrgNames,omitempty"` +} + +type _IssuerCreateRequest IssuerCreateRequest + +// NewIssuerCreateRequest instantiates a new IssuerCreateRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewIssuerCreateRequest(name string, issuerUrl string, jwksUrl string, claimMappings []IssuerClaimMapping) *IssuerCreateRequest { + this := IssuerCreateRequest{} + this.Name = name + this.IssuerUrl = issuerUrl + this.JwksUrl = jwksUrl + var origin string = "custom" + this.Origin = &origin + var serviceAccount bool = false + this.ServiceAccount = &serviceAccount + this.ClaimMappings = claimMappings + var allowDuplicateStaticOrgNames bool = false + this.AllowDuplicateStaticOrgNames = &allowDuplicateStaticOrgNames + return &this +} + +// NewIssuerCreateRequestWithDefaults instantiates a new IssuerCreateRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewIssuerCreateRequestWithDefaults() *IssuerCreateRequest { + this := IssuerCreateRequest{} + var origin string = "custom" + this.Origin = &origin + var serviceAccount bool = false + this.ServiceAccount = &serviceAccount + var allowDuplicateStaticOrgNames bool = false + this.AllowDuplicateStaticOrgNames = &allowDuplicateStaticOrgNames + return &this +} + +// GetName returns the Name field value +func (o *IssuerCreateRequest) GetName() string { + if o == nil { + var ret string + return ret + } + + return o.Name +} + +// GetNameOk returns a tuple with the Name field value +// and a boolean to check if the value has been set. +func (o *IssuerCreateRequest) GetNameOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Name, true +} + +// SetName sets field value +func (o *IssuerCreateRequest) SetName(v string) { + o.Name = v +} + +// GetIssuerUrl returns the IssuerUrl field value +func (o *IssuerCreateRequest) GetIssuerUrl() string { + if o == nil { + var ret string + return ret + } + + return o.IssuerUrl +} + +// GetIssuerUrlOk returns a tuple with the IssuerUrl field value +// and a boolean to check if the value has been set. +func (o *IssuerCreateRequest) GetIssuerUrlOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.IssuerUrl, true +} + +// SetIssuerUrl sets field value +func (o *IssuerCreateRequest) SetIssuerUrl(v string) { + o.IssuerUrl = v +} + +// GetJwksUrl returns the JwksUrl field value +func (o *IssuerCreateRequest) GetJwksUrl() string { + if o == nil { + var ret string + return ret + } + + return o.JwksUrl +} + +// GetJwksUrlOk returns a tuple with the JwksUrl field value +// and a boolean to check if the value has been set. +func (o *IssuerCreateRequest) GetJwksUrlOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.JwksUrl, true +} + +// SetJwksUrl sets field value +func (o *IssuerCreateRequest) SetJwksUrl(v string) { + o.JwksUrl = v +} + +// GetOrigin returns the Origin field value if set, zero value otherwise. +func (o *IssuerCreateRequest) GetOrigin() string { + if o == nil || IsNil(o.Origin) { + var ret string + return ret + } + return *o.Origin +} + +// GetOriginOk returns a tuple with the Origin field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerCreateRequest) GetOriginOk() (*string, bool) { + if o == nil || IsNil(o.Origin) { + return nil, false + } + return o.Origin, true +} + +// HasOrigin returns a boolean if a field has been set. +func (o *IssuerCreateRequest) HasOrigin() bool { + if o != nil && !IsNil(o.Origin) { + return true + } + + return false +} + +// SetOrigin gets a reference to the given string and assigns it to the Origin field. +func (o *IssuerCreateRequest) SetOrigin(v string) { + o.Origin = &v +} + +// GetServiceAccount returns the ServiceAccount field value if set, zero value otherwise. +func (o *IssuerCreateRequest) GetServiceAccount() bool { + if o == nil || IsNil(o.ServiceAccount) { + var ret bool + return ret + } + return *o.ServiceAccount +} + +// GetServiceAccountOk returns a tuple with the ServiceAccount field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerCreateRequest) GetServiceAccountOk() (*bool, bool) { + if o == nil || IsNil(o.ServiceAccount) { + return nil, false + } + return o.ServiceAccount, true +} + +// HasServiceAccount returns a boolean if a field has been set. +func (o *IssuerCreateRequest) HasServiceAccount() bool { + if o != nil && !IsNil(o.ServiceAccount) { + return true + } + + return false +} + +// SetServiceAccount gets a reference to the given bool and assigns it to the ServiceAccount field. +func (o *IssuerCreateRequest) SetServiceAccount(v bool) { + o.ServiceAccount = &v +} + +// GetAudiences returns the Audiences field value if set, zero value otherwise. +func (o *IssuerCreateRequest) GetAudiences() []string { + if o == nil || IsNil(o.Audiences) { + var ret []string + return ret + } + return o.Audiences +} + +// GetAudiencesOk returns a tuple with the Audiences field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerCreateRequest) GetAudiencesOk() ([]string, bool) { + if o == nil || IsNil(o.Audiences) { + return nil, false + } + return o.Audiences, true +} + +// HasAudiences returns a boolean if a field has been set. +func (o *IssuerCreateRequest) HasAudiences() bool { + if o != nil && !IsNil(o.Audiences) { + return true + } + + return false +} + +// SetAudiences gets a reference to the given []string and assigns it to the Audiences field. +func (o *IssuerCreateRequest) SetAudiences(v []string) { + o.Audiences = v +} + +// GetScopes returns the Scopes field value if set, zero value otherwise. +func (o *IssuerCreateRequest) GetScopes() []string { + if o == nil || IsNil(o.Scopes) { + var ret []string + return ret + } + return o.Scopes +} + +// GetScopesOk returns a tuple with the Scopes field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerCreateRequest) GetScopesOk() ([]string, bool) { + if o == nil || IsNil(o.Scopes) { + return nil, false + } + return o.Scopes, true +} + +// HasScopes returns a boolean if a field has been set. +func (o *IssuerCreateRequest) HasScopes() bool { + if o != nil && !IsNil(o.Scopes) { + return true + } + + return false +} + +// SetScopes gets a reference to the given []string and assigns it to the Scopes field. +func (o *IssuerCreateRequest) SetScopes(v []string) { + o.Scopes = v +} + +// GetJwksTimeout returns the JwksTimeout field value if set, zero value otherwise. +func (o *IssuerCreateRequest) GetJwksTimeout() string { + if o == nil || IsNil(o.JwksTimeout) { + var ret string + return ret + } + return *o.JwksTimeout +} + +// GetJwksTimeoutOk returns a tuple with the JwksTimeout field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerCreateRequest) GetJwksTimeoutOk() (*string, bool) { + if o == nil || IsNil(o.JwksTimeout) { + return nil, false + } + return o.JwksTimeout, true +} + +// HasJwksTimeout returns a boolean if a field has been set. +func (o *IssuerCreateRequest) HasJwksTimeout() bool { + if o != nil && !IsNil(o.JwksTimeout) { + return true + } + + return false +} + +// SetJwksTimeout gets a reference to the given string and assigns it to the JwksTimeout field. +func (o *IssuerCreateRequest) SetJwksTimeout(v string) { + o.JwksTimeout = &v +} + +// GetClaimMappings returns the ClaimMappings field value +func (o *IssuerCreateRequest) GetClaimMappings() []IssuerClaimMapping { + if o == nil { + var ret []IssuerClaimMapping + return ret + } + + return o.ClaimMappings +} + +// GetClaimMappingsOk returns a tuple with the ClaimMappings field value +// and a boolean to check if the value has been set. +func (o *IssuerCreateRequest) GetClaimMappingsOk() ([]IssuerClaimMapping, bool) { + if o == nil { + return nil, false + } + return o.ClaimMappings, true +} + +// SetClaimMappings sets field value +func (o *IssuerCreateRequest) SetClaimMappings(v []IssuerClaimMapping) { + o.ClaimMappings = v +} + +// GetAllowDuplicateStaticOrgNames returns the AllowDuplicateStaticOrgNames field value if set, zero value otherwise. +func (o *IssuerCreateRequest) GetAllowDuplicateStaticOrgNames() bool { + if o == nil || IsNil(o.AllowDuplicateStaticOrgNames) { + var ret bool + return ret + } + return *o.AllowDuplicateStaticOrgNames +} + +// GetAllowDuplicateStaticOrgNamesOk returns a tuple with the AllowDuplicateStaticOrgNames field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerCreateRequest) GetAllowDuplicateStaticOrgNamesOk() (*bool, bool) { + if o == nil || IsNil(o.AllowDuplicateStaticOrgNames) { + return nil, false + } + return o.AllowDuplicateStaticOrgNames, true +} + +// HasAllowDuplicateStaticOrgNames returns a boolean if a field has been set. +func (o *IssuerCreateRequest) HasAllowDuplicateStaticOrgNames() bool { + if o != nil && !IsNil(o.AllowDuplicateStaticOrgNames) { + return true + } + + return false +} + +// SetAllowDuplicateStaticOrgNames gets a reference to the given bool and assigns it to the AllowDuplicateStaticOrgNames field. +func (o *IssuerCreateRequest) SetAllowDuplicateStaticOrgNames(v bool) { + o.AllowDuplicateStaticOrgNames = &v +} + +func (o IssuerCreateRequest) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o IssuerCreateRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["name"] = o.Name + toSerialize["issuerUrl"] = o.IssuerUrl + toSerialize["jwksUrl"] = o.JwksUrl + if !IsNil(o.Origin) { + toSerialize["origin"] = o.Origin + } + if !IsNil(o.ServiceAccount) { + toSerialize["serviceAccount"] = o.ServiceAccount + } + if !IsNil(o.Audiences) { + toSerialize["audiences"] = o.Audiences + } + if !IsNil(o.Scopes) { + toSerialize["scopes"] = o.Scopes + } + if !IsNil(o.JwksTimeout) { + toSerialize["jwksTimeout"] = o.JwksTimeout + } + toSerialize["claimMappings"] = o.ClaimMappings + if !IsNil(o.AllowDuplicateStaticOrgNames) { + toSerialize["allowDuplicateStaticOrgNames"] = o.AllowDuplicateStaticOrgNames + } + return toSerialize, nil +} + +func (o *IssuerCreateRequest) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "name", + "issuerUrl", + "jwksUrl", + "claimMappings", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varIssuerCreateRequest := _IssuerCreateRequest{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varIssuerCreateRequest) + + if err != nil { + return err + } + + *o = IssuerCreateRequest(varIssuerCreateRequest) + + return err +} + +type NullableIssuerCreateRequest struct { + value *IssuerCreateRequest + isSet bool +} + +func (v NullableIssuerCreateRequest) Get() *IssuerCreateRequest { + return v.value +} + +func (v *NullableIssuerCreateRequest) Set(val *IssuerCreateRequest) { + v.value = val + v.isSet = true +} + +func (v NullableIssuerCreateRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableIssuerCreateRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableIssuerCreateRequest(val *IssuerCreateRequest) *NullableIssuerCreateRequest { + return &NullableIssuerCreateRequest{value: val, isSet: true} +} + +func (v NullableIssuerCreateRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableIssuerCreateRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_issuer_status.go b/rest-api/sdk/standard/model_issuer_status.go new file mode 100644 index 0000000000..7f02aff9f8 --- /dev/null +++ b/rest-api/sdk/standard/model_issuer_status.go @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" + "fmt" +) + +// IssuerStatus Whether the issuer's JWKS was reachable during the most recent apply or boot seed attempt. +type IssuerStatus string + +// List of IssuerStatus +const ( + ISSUERSTATUS_PENDING IssuerStatus = "Pending" + ISSUERSTATUS_READY IssuerStatus = "Ready" +) + +// All allowed values of IssuerStatus enum +var AllowedIssuerStatusEnumValues = []IssuerStatus{ + "Pending", + "Ready", +} + +func (v *IssuerStatus) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := IssuerStatus(value) + for _, existing := range AllowedIssuerStatusEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid IssuerStatus", value) +} + +// NewIssuerStatusFromValue returns a pointer to a valid IssuerStatus +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewIssuerStatusFromValue(v string) (*IssuerStatus, error) { + ev := IssuerStatus(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for IssuerStatus: valid values are %v", v, AllowedIssuerStatusEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v IssuerStatus) IsValid() bool { + for _, existing := range AllowedIssuerStatusEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to IssuerStatus value +func (v IssuerStatus) Ptr() *IssuerStatus { + return &v +} + +type NullableIssuerStatus struct { + value *IssuerStatus + isSet bool +} + +func (v NullableIssuerStatus) Get() *IssuerStatus { + return v.value +} + +func (v *NullableIssuerStatus) Set(val *IssuerStatus) { + v.value = val + v.isSet = true +} + +func (v NullableIssuerStatus) IsSet() bool { + return v.isSet +} + +func (v *NullableIssuerStatus) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableIssuerStatus(val *IssuerStatus) *NullableIssuerStatus { + return &NullableIssuerStatus{value: val, isSet: true} +} + +func (v NullableIssuerStatus) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableIssuerStatus) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/rest-api/sdk/standard/model_issuer_update_request.go b/rest-api/sdk/standard/model_issuer_update_request.go new file mode 100644 index 0000000000..fb0a44f97a --- /dev/null +++ b/rest-api/sdk/standard/model_issuer_update_request.go @@ -0,0 +1,442 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/* +NVIDIA Infra Controller REST API + +NVIDIA Infra Controller REST API allows users to create and manage resources, e.g., VPCs, Subnets, and Instances, across all connected NVIDIA Infra Controller datacenters, also referred to as Sites. + +API version: 1.6.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package standard + +import ( + "encoding/json" +) + +// checks if the IssuerUpdateRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &IssuerUpdateRequest{} + +// IssuerUpdateRequest Request to update a dynamic custom issuer. `issuerUrl` and `origin` are immutable. +type IssuerUpdateRequest struct { + // Human-readable name for the issuer binding. + Name NullableString `json:"name,omitempty"` + // JWKS endpoint used to verify token signatures. + JwksUrl NullableString `json:"jwksUrl,omitempty"` + // Must be false for dynamic custom issuer registrations. Use `claimMappings[].isServiceAccount` for custom service-account mappings. + ServiceAccount NullableBool `json:"serviceAccount,omitempty"` + // Replacement allowed audience list. Omit the field to leave it unchanged. + Audiences []string `json:"audiences,omitempty"` + // Replacement required scope list. Omit the field to leave it unchanged. + Scopes []string `json:"scopes,omitempty"` + // JWKS fetch timeout as a Go duration string, for example `5s` or `1m`. + JwksTimeout NullableString `json:"jwksTimeout,omitempty"` + // Replacement claim mappings. Omit the field to leave mappings unchanged. + ClaimMappings []IssuerClaimMapping `json:"claimMappings,omitempty"` + // Allows this issuer's static `orgName` mappings to duplicate static org names from other issuers. + AllowDuplicateStaticOrgNames NullableBool `json:"allowDuplicateStaticOrgNames,omitempty"` +} + +// NewIssuerUpdateRequest instantiates a new IssuerUpdateRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewIssuerUpdateRequest() *IssuerUpdateRequest { + this := IssuerUpdateRequest{} + return &this +} + +// NewIssuerUpdateRequestWithDefaults instantiates a new IssuerUpdateRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewIssuerUpdateRequestWithDefaults() *IssuerUpdateRequest { + this := IssuerUpdateRequest{} + return &this +} + +// GetName returns the Name field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *IssuerUpdateRequest) GetName() string { + if o == nil || IsNil(o.Name.Get()) { + var ret string + return ret + } + return *o.Name.Get() +} + +// GetNameOk returns a tuple with the Name field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *IssuerUpdateRequest) GetNameOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Name.Get(), o.Name.IsSet() +} + +// HasName returns a boolean if a field has been set. +func (o *IssuerUpdateRequest) HasName() bool { + if o != nil && o.Name.IsSet() { + return true + } + + return false +} + +// SetName gets a reference to the given NullableString and assigns it to the Name field. +func (o *IssuerUpdateRequest) SetName(v string) { + o.Name.Set(&v) +} + +// SetNameNil sets the value for Name to be an explicit nil +func (o *IssuerUpdateRequest) SetNameNil() { + o.Name.Set(nil) +} + +// UnsetName ensures that no value is present for Name, not even an explicit nil +func (o *IssuerUpdateRequest) UnsetName() { + o.Name.Unset() +} + +// GetJwksUrl returns the JwksUrl field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *IssuerUpdateRequest) GetJwksUrl() string { + if o == nil || IsNil(o.JwksUrl.Get()) { + var ret string + return ret + } + return *o.JwksUrl.Get() +} + +// GetJwksUrlOk returns a tuple with the JwksUrl field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *IssuerUpdateRequest) GetJwksUrlOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.JwksUrl.Get(), o.JwksUrl.IsSet() +} + +// HasJwksUrl returns a boolean if a field has been set. +func (o *IssuerUpdateRequest) HasJwksUrl() bool { + if o != nil && o.JwksUrl.IsSet() { + return true + } + + return false +} + +// SetJwksUrl gets a reference to the given NullableString and assigns it to the JwksUrl field. +func (o *IssuerUpdateRequest) SetJwksUrl(v string) { + o.JwksUrl.Set(&v) +} + +// SetJwksUrlNil sets the value for JwksUrl to be an explicit nil +func (o *IssuerUpdateRequest) SetJwksUrlNil() { + o.JwksUrl.Set(nil) +} + +// UnsetJwksUrl ensures that no value is present for JwksUrl, not even an explicit nil +func (o *IssuerUpdateRequest) UnsetJwksUrl() { + o.JwksUrl.Unset() +} + +// GetServiceAccount returns the ServiceAccount field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *IssuerUpdateRequest) GetServiceAccount() bool { + if o == nil || IsNil(o.ServiceAccount.Get()) { + var ret bool + return ret + } + return *o.ServiceAccount.Get() +} + +// GetServiceAccountOk returns a tuple with the ServiceAccount field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *IssuerUpdateRequest) GetServiceAccountOk() (*bool, bool) { + if o == nil { + return nil, false + } + return o.ServiceAccount.Get(), o.ServiceAccount.IsSet() +} + +// HasServiceAccount returns a boolean if a field has been set. +func (o *IssuerUpdateRequest) HasServiceAccount() bool { + if o != nil && o.ServiceAccount.IsSet() { + return true + } + + return false +} + +// SetServiceAccount gets a reference to the given NullableBool and assigns it to the ServiceAccount field. +func (o *IssuerUpdateRequest) SetServiceAccount(v bool) { + o.ServiceAccount.Set(&v) +} + +// SetServiceAccountNil sets the value for ServiceAccount to be an explicit nil +func (o *IssuerUpdateRequest) SetServiceAccountNil() { + o.ServiceAccount.Set(nil) +} + +// UnsetServiceAccount ensures that no value is present for ServiceAccount, not even an explicit nil +func (o *IssuerUpdateRequest) UnsetServiceAccount() { + o.ServiceAccount.Unset() +} + +// GetAudiences returns the Audiences field value if set, zero value otherwise. +func (o *IssuerUpdateRequest) GetAudiences() []string { + if o == nil || IsNil(o.Audiences) { + var ret []string + return ret + } + return o.Audiences +} + +// GetAudiencesOk returns a tuple with the Audiences field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerUpdateRequest) GetAudiencesOk() ([]string, bool) { + if o == nil || IsNil(o.Audiences) { + return nil, false + } + return o.Audiences, true +} + +// HasAudiences returns a boolean if a field has been set. +func (o *IssuerUpdateRequest) HasAudiences() bool { + if o != nil && !IsNil(o.Audiences) { + return true + } + + return false +} + +// SetAudiences gets a reference to the given []string and assigns it to the Audiences field. +func (o *IssuerUpdateRequest) SetAudiences(v []string) { + o.Audiences = v +} + +// GetScopes returns the Scopes field value if set, zero value otherwise. +func (o *IssuerUpdateRequest) GetScopes() []string { + if o == nil || IsNil(o.Scopes) { + var ret []string + return ret + } + return o.Scopes +} + +// GetScopesOk returns a tuple with the Scopes field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerUpdateRequest) GetScopesOk() ([]string, bool) { + if o == nil || IsNil(o.Scopes) { + return nil, false + } + return o.Scopes, true +} + +// HasScopes returns a boolean if a field has been set. +func (o *IssuerUpdateRequest) HasScopes() bool { + if o != nil && !IsNil(o.Scopes) { + return true + } + + return false +} + +// SetScopes gets a reference to the given []string and assigns it to the Scopes field. +func (o *IssuerUpdateRequest) SetScopes(v []string) { + o.Scopes = v +} + +// GetJwksTimeout returns the JwksTimeout field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *IssuerUpdateRequest) GetJwksTimeout() string { + if o == nil || IsNil(o.JwksTimeout.Get()) { + var ret string + return ret + } + return *o.JwksTimeout.Get() +} + +// GetJwksTimeoutOk returns a tuple with the JwksTimeout field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *IssuerUpdateRequest) GetJwksTimeoutOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.JwksTimeout.Get(), o.JwksTimeout.IsSet() +} + +// HasJwksTimeout returns a boolean if a field has been set. +func (o *IssuerUpdateRequest) HasJwksTimeout() bool { + if o != nil && o.JwksTimeout.IsSet() { + return true + } + + return false +} + +// SetJwksTimeout gets a reference to the given NullableString and assigns it to the JwksTimeout field. +func (o *IssuerUpdateRequest) SetJwksTimeout(v string) { + o.JwksTimeout.Set(&v) +} + +// SetJwksTimeoutNil sets the value for JwksTimeout to be an explicit nil +func (o *IssuerUpdateRequest) SetJwksTimeoutNil() { + o.JwksTimeout.Set(nil) +} + +// UnsetJwksTimeout ensures that no value is present for JwksTimeout, not even an explicit nil +func (o *IssuerUpdateRequest) UnsetJwksTimeout() { + o.JwksTimeout.Unset() +} + +// GetClaimMappings returns the ClaimMappings field value if set, zero value otherwise. +func (o *IssuerUpdateRequest) GetClaimMappings() []IssuerClaimMapping { + if o == nil || IsNil(o.ClaimMappings) { + var ret []IssuerClaimMapping + return ret + } + return o.ClaimMappings +} + +// GetClaimMappingsOk returns a tuple with the ClaimMappings field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *IssuerUpdateRequest) GetClaimMappingsOk() ([]IssuerClaimMapping, bool) { + if o == nil || IsNil(o.ClaimMappings) { + return nil, false + } + return o.ClaimMappings, true +} + +// HasClaimMappings returns a boolean if a field has been set. +func (o *IssuerUpdateRequest) HasClaimMappings() bool { + if o != nil && !IsNil(o.ClaimMappings) { + return true + } + + return false +} + +// SetClaimMappings gets a reference to the given []IssuerClaimMapping and assigns it to the ClaimMappings field. +func (o *IssuerUpdateRequest) SetClaimMappings(v []IssuerClaimMapping) { + o.ClaimMappings = v +} + +// GetAllowDuplicateStaticOrgNames returns the AllowDuplicateStaticOrgNames field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *IssuerUpdateRequest) GetAllowDuplicateStaticOrgNames() bool { + if o == nil || IsNil(o.AllowDuplicateStaticOrgNames.Get()) { + var ret bool + return ret + } + return *o.AllowDuplicateStaticOrgNames.Get() +} + +// GetAllowDuplicateStaticOrgNamesOk returns a tuple with the AllowDuplicateStaticOrgNames field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *IssuerUpdateRequest) GetAllowDuplicateStaticOrgNamesOk() (*bool, bool) { + if o == nil { + return nil, false + } + return o.AllowDuplicateStaticOrgNames.Get(), o.AllowDuplicateStaticOrgNames.IsSet() +} + +// HasAllowDuplicateStaticOrgNames returns a boolean if a field has been set. +func (o *IssuerUpdateRequest) HasAllowDuplicateStaticOrgNames() bool { + if o != nil && o.AllowDuplicateStaticOrgNames.IsSet() { + return true + } + + return false +} + +// SetAllowDuplicateStaticOrgNames gets a reference to the given NullableBool and assigns it to the AllowDuplicateStaticOrgNames field. +func (o *IssuerUpdateRequest) SetAllowDuplicateStaticOrgNames(v bool) { + o.AllowDuplicateStaticOrgNames.Set(&v) +} + +// SetAllowDuplicateStaticOrgNamesNil sets the value for AllowDuplicateStaticOrgNames to be an explicit nil +func (o *IssuerUpdateRequest) SetAllowDuplicateStaticOrgNamesNil() { + o.AllowDuplicateStaticOrgNames.Set(nil) +} + +// UnsetAllowDuplicateStaticOrgNames ensures that no value is present for AllowDuplicateStaticOrgNames, not even an explicit nil +func (o *IssuerUpdateRequest) UnsetAllowDuplicateStaticOrgNames() { + o.AllowDuplicateStaticOrgNames.Unset() +} + +func (o IssuerUpdateRequest) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o IssuerUpdateRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if o.Name.IsSet() { + toSerialize["name"] = o.Name.Get() + } + if o.JwksUrl.IsSet() { + toSerialize["jwksUrl"] = o.JwksUrl.Get() + } + if o.ServiceAccount.IsSet() { + toSerialize["serviceAccount"] = o.ServiceAccount.Get() + } + if !IsNil(o.Audiences) { + toSerialize["audiences"] = o.Audiences + } + if !IsNil(o.Scopes) { + toSerialize["scopes"] = o.Scopes + } + if o.JwksTimeout.IsSet() { + toSerialize["jwksTimeout"] = o.JwksTimeout.Get() + } + if !IsNil(o.ClaimMappings) { + toSerialize["claimMappings"] = o.ClaimMappings + } + if o.AllowDuplicateStaticOrgNames.IsSet() { + toSerialize["allowDuplicateStaticOrgNames"] = o.AllowDuplicateStaticOrgNames.Get() + } + return toSerialize, nil +} + +type NullableIssuerUpdateRequest struct { + value *IssuerUpdateRequest + isSet bool +} + +func (v NullableIssuerUpdateRequest) Get() *IssuerUpdateRequest { + return v.value +} + +func (v *NullableIssuerUpdateRequest) Set(val *IssuerUpdateRequest) { + v.value = val + v.isSet = true +} + +func (v NullableIssuerUpdateRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableIssuerUpdateRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableIssuerUpdateRequest(val *IssuerUpdateRequest) *NullableIssuerUpdateRequest { + return &NullableIssuerUpdateRequest{value: val, isSet: true} +} + +func (v NullableIssuerUpdateRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableIssuerUpdateRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} From d3925dea18afa0b088adf5f4148ae4fd7f5ae782 Mon Sep 17 00:00:00 2001 From: Jan Baraniewski Date: Fri, 19 Jun 2026 16:53:17 +0200 Subject: [PATCH 4/4] test(auth): integration test for dynamic issuer hot-apply Cover the hot-apply verify seam end to end: a token signed by a freshly registered issuer's key is accepted for its bound org, rejected for another org, and rejected after deregistration. Also cover boot seeding: statically configured issuer URLs are skipped, a JWKS fetch failure is non-fatal, and the reserved-org set is the union of the config static orgs and the DB issuers' static orgs. Signed-off-by: Jan Baraniewski --- rest-api/api/internal/config/issuer_test.go | 245 ++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 rest-api/api/internal/config/issuer_test.go diff --git a/rest-api/api/internal/config/issuer_test.go b/rest-api/api/internal/config/issuer_test.go new file mode 100644 index 0000000000..96c842b3c9 --- /dev/null +++ b/rest-api/api/internal/config/issuer_test.go @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + authz "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/authorization" + cauth "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/config" + "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/processors" + testutil "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/testing" + cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model" + cdbu "github.com/NVIDIA/infra-controller/rest-api/db/pkg/util" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testIssuerKID = "test-key-id" + +// jwksServerForKey serves a single-RSA-key JWKS. +func jwksServerForKey(t *testing.T, key *rsa.PrivateKey) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "keys": []map[string]interface{}{{ + "kty": "RSA", + "kid": testIssuerKID, + "use": "sig", + "alg": "RS256", + "n": testutil.EncodeBase64URLBigInt(key.N), + "e": testutil.EncodeBase64URLBigInt(big.NewInt(int64(key.E))), + }}, + }) + })) + t.Cleanup(srv.Close) + return srv +} + +func emptyJWKSServer(t *testing.T) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"keys":[]}`)) + })) + t.Cleanup(srv.Close) + return srv +} + +func mintToken(t *testing.T, key *rsa.PrivateKey, issuerURL, sub string) string { + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": issuerURL, + "sub": sub, + "email": "user@acme.example", + "iat": jwt.NewNumericDate(time.Now()), + "exp": jwt.NewNumericDate(time.Now().Add(time.Hour)), + }) + tok.Header["kid"] = testIssuerKID + s, err := tok.SignedString(key) + require.NoError(t, err) + return s +} + +func echoCtxForOrg(routeOrg string) echo.Context { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/", nil) + ec := e.NewContext(req, httptest.NewRecorder()) + ec.SetParamNames("orgName") + ec.SetParamValues(routeOrg) + return ec +} + +// TestApplyIssuer_VerifySeam verifies a hot-applied issuer's token is accepted for +// its bound org, rejected for another org, and rejected after deregistration. +func TestApplyIssuer_VerifySeam(t *testing.T) { + ctx := context.Background() + dbSession := cdbu.GetTestDBSession(t, false) + defer dbSession.Close() + require.Nil(t, dbSession.DB.ResetModel(ctx, (*cdbm.User)(nil))) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + jwksSrv := jwksServerForKey(t, key) + + cfg := NewConfig() + cfg.JwtOriginConfig = cauth.NewJWTOriginConfig() + cfg.JwtOriginConfig.SetProcessorForOrigin(cauth.TokenOriginCustom, processors.NewCustomProcessor(dbSession)) + + const issuerURL = "https://idp.acme.example" + iss := &cdbm.Issuer{ + ID: uuid.New(), + Name: "acme-idp", + IssuerURL: issuerURL, + JWKSURL: jwksSrv.URL, + Origin: cauth.TokenOriginCustom, + ClaimMappings: []cdbm.IssuerClaimMapping{{ + OrgName: "tenant-acme", + Roles: []string{authz.TenantAdminRole}, + }}, + Status: cdbm.IssuerStatusPending, + } + + // Hot-apply the issuer. + require.NoError(t, cfg.ApplyIssuer(iss)) + + jo := cfg.JwtOriginConfig + require.NotNil(t, jo.GetConfig(issuerURL), "ApplyIssuer must install a JwksConfig keyed by the issuer URL") + proc := jo.GetProcessorByIssuer(issuerURL) + require.NotNil(t, proc, "issuer must resolve to the custom processor") + + logger := zerolog.Nop() + token := mintToken(t, key, issuerURL, "user-1") + + // Own-org request -> accepted, org resolved to the registered binding. + ecOwn := echoCtxForOrg("tenant-acme") + user, apiErr := proc.ProcessToken(ecOwn, token, jo.GetConfig(issuerURL), logger) + require.Nil(t, apiErr, "token from a registered issuer must be accepted for its bound org") + require.NotNil(t, user) + _, oerr := user.OrgData.GetOrgByName("tenant-acme") + assert.NoError(t, oerr, "verified user must carry the issuer's pinned org") + + // Cross-org request (same valid token, different route org) -> rejected. + ecCross := echoCtxForOrg("tenant-globex") + _, apiErr = proc.ProcessToken(ecCross, token, jo.GetConfig(issuerURL), logger) + require.NotNil(t, apiErr, "token bound to tenant-acme must not authorize tenant-globex") + assert.Equal(t, http.StatusUnauthorized, apiErr.Code) + + // Deregister -> issuer no longer resolves (middleware would return 401). + cfg.RemoveIssuer(issuerURL) + assert.Nil(t, jo.GetConfig(issuerURL)) + assert.Nil(t, jo.GetProcessorByIssuer(issuerURL)) +} + +func TestApplyIssuer_FailedFetchReplacesLiveConfig(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + reachable := jwksServerForKey(t, key) + unreachable := emptyJWKSServer(t) + + cfg := &Config{ + v: newViper(), + JwtOriginConfig: cauth.NewJWTOriginConfig(), + } + + const issuerURL = "https://idp.acme.example" + oldCfg := cauth.NewJwksConfig("old-idp", reachable.URL, issuerURL, cauth.TokenOriginCustom, false, nil, nil) + oldCfg.ClaimMappings = []cauth.ClaimMapping{{OrgName: "tenant-old", Roles: []string{authz.TenantAdminRole}}} + require.NoError(t, oldCfg.UpdateJWKS()) + require.Positive(t, oldCfg.KeyCount()) + cfg.JwtOriginConfig.AddJwksConfig(oldCfg) + + err = cfg.ApplyIssuer(&cdbm.Issuer{ + ID: uuid.New(), + Name: "new-idp", + IssuerURL: issuerURL, + JWKSURL: unreachable.URL, + Origin: cauth.TokenOriginCustom, + ClaimMappings: []cdbm.IssuerClaimMapping{{ + OrgName: "tenant-new", + Roles: []string{authz.TenantAdminRole}, + }}, + Status: cdbm.IssuerStatusPending, + }) + + require.Error(t, err) + got := cfg.JwtOriginConfig.GetConfig(issuerURL) + require.NotNil(t, got) + assert.Equal(t, "new-idp", got.Name) + assert.Equal(t, unreachable.URL, got.URL) + assert.Zero(t, got.KeyCount(), "failed hot-apply must not keep old JWKS keys live") + require.Len(t, got.ClaimMappings, 1) + assert.Equal(t, "tenant-new", got.ClaimMappings[0].OrgName) +} + +// TestSeedIssuersFromDB verifies boot seeding: static-config issuers are skipped, +// an unreachable JWKS is non-fatal, and the reserved org set is the union of +// config-static orgs and the DB issuers' static orgs. +func TestSeedIssuersFromDB(t *testing.T) { + ctx := context.Background() + dbSession := cdbu.GetTestDBSession(t, false) + defer dbSession.Close() + require.NoError(t, dbSession.DB.ResetModel(ctx, (*cdbm.Issuer)(nil))) + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + reachable := jwksServerForKey(t, key) + unreachable := emptyJWKSServer(t) + + dao := cdbm.NewIssuerDAO(dbSession) + // reachable issuer -> applied with keys + _, err = dao.Create(ctx, nil, cdbm.IssuerCreateInput{ + Name: "reachable", IssuerURL: "https://idp.reachable.example", JWKSURL: reachable.URL, Origin: cauth.TokenOriginCustom, + ClaimMappings: []cdbm.IssuerClaimMapping{{OrgName: "tenant-reachable", Roles: []string{"TENANT_ADMIN"}}}, + Status: cdbm.IssuerStatusPending, CreatedBy: uuid.New(), + }) + require.NoError(t, err) + // unreachable JWKS -> non-fatal, still installed for lazy refresh + _, err = dao.Create(ctx, nil, cdbm.IssuerCreateInput{ + Name: "pending", IssuerURL: "https://idp.pending.example", JWKSURL: unreachable.URL, Origin: cauth.TokenOriginCustom, + ClaimMappings: []cdbm.IssuerClaimMapping{{OrgName: "tenant-pending", Roles: []string{"TENANT_ADMIN"}}}, + Status: cdbm.IssuerStatusReady, CreatedBy: uuid.New(), + }) + require.NoError(t, err) + // issuer URL matching a static config issuer (authn.nvidia.com from config.yaml) -> skipped + _, err = dao.Create(ctx, nil, cdbm.IssuerCreateInput{ + Name: "shadow-static", IssuerURL: "authn.nvidia.com", JWKSURL: unreachable.URL, Origin: cauth.TokenOriginKasLegacy, + Status: cdbm.IssuerStatusPending, CreatedBy: uuid.New(), + }) + require.NoError(t, err) + + cfg := NewConfig() + cfg.JwtOriginConfig = cauth.NewJWTOriginConfig() + + require.NoError(t, cfg.SeedIssuersFromDB(ctx, dbSession)) + + jo := cfg.JwtOriginConfig + require.NotNil(t, jo.GetConfig("https://idp.reachable.example")) + assert.Positive(t, jo.GetConfig("https://idp.reachable.example").KeyCount(), "reachable issuer JWKS should be fetched") + require.NotNil(t, jo.GetConfig("https://idp.pending.example"), "unreachable issuer is still installed for lazy refresh") + assert.Nil(t, jo.GetConfig("authn.nvidia.com"), "statically-configured issuer URL must be skipped") + + reachableRow, err := dao.GetByIssuerURL(ctx, nil, "https://idp.reachable.example") + require.NoError(t, err) + assert.Equal(t, cdbm.IssuerStatusReady, reachableRow.Status) + pendingRow, err := dao.GetByIssuerURL(ctx, nil, "https://idp.pending.example") + require.NoError(t, err) + assert.Equal(t, cdbm.IssuerStatusPending, pendingRow.Status) + + // reserved org set includes the DB issuers' static orgs. Every installed + // config is wired to the shared set, so assert via a wired config's own + // ReservedOrgNames (the same read path the auth hot-path uses). + reachableCfg := jo.GetConfig("https://idp.reachable.example") + require.NotNil(t, reachableCfg.ReservedOrgNames, "installed config must be wired to the reserved-org set") + assert.True(t, reachableCfg.ReservedOrgNames.Has("tenant-reachable")) + assert.True(t, reachableCfg.ReservedOrgNames.Has("tenant-pending")) +}