Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions rest-api/api/pkg/api/handler/ueficredential.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package handler

import (
"net/http"

"github.com/labstack/echo/v4"

"github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common"
"github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model"
sc "github.com/NVIDIA/infra-controller/rest-api/api/pkg/client/site"
cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util"
cdb "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db"
)

// CreateUEFICredentialHandler creates a site-default host or DPU UEFI credential.
type CreateUEFICredentialHandler struct {
dbSession *cdb.Session
scp *sc.ClientPool
tracerSpan *cutil.TracerSpan
}

// NewCreateUEFICredentialHandler returns a handler for creating a UEFI credential.
func NewCreateUEFICredentialHandler(dbSession *cdb.Session, scp *sc.ClientPool) CreateUEFICredentialHandler {
return CreateUEFICredentialHandler{
dbSession: dbSession,
scp: scp,
tracerSpan: cutil.NewTracerSpan(),
}
}

// Handle godoc
// @Summary Create UEFI Credential
// @Description Create a site-default host or DPU UEFI credential. Equivalent to `nico-admin-cli credential add-uefi`.
// @Tags uefi-credential
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param org path string true "Name of NGC organization"
// @Param request body model.APIUEFICredentialRequest true "UEFI credential"
// @Success 201 {object} model.APIUEFICredential
// @Router /v2/org/{org}/nico/credential/uefi [post]
func (h CreateUEFICredentialHandler) Handle(c echo.Context) error {
org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("UEFICredential", "Create", c, h.tracerSpan)
if handlerSpan != nil {
defer handlerSpan.End()
}

var apiReq model.APIUEFICredentialRequest
if err := c.Bind(&apiReq); err != nil {
return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid request body", nil)
}
if err := apiReq.Validate(); err != nil {
return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, err.Error(), nil)
}

stc, siteID, apiErr := common.AuthorizeProviderSiteForCore(common.AuthorizeProviderSiteForCoreInput{
Ctx: ctx,
Logger: logger,
DBSession: h.dbSession,
SCP: h.scp,
Org: org,
User: dbUser,
SiteID: apiReq.SiteID,
})
if apiErr != nil {
return cutil.NewAPIErrorResponse(c, apiErr.Code, apiErr.Message, apiErr.Data)
}

logger.Info().Str("kind", string(apiReq.Kind)).Str("siteID", apiReq.SiteID).Msg("creating UEFI credential via Core proxy")

if apiErr := common.ExecuteCoreGRPC(ctx, stc, createCredentialMethod, apiReq.ToProto(), nil, siteID, "password"); apiErr != nil {
logAPIError(logger, apiErr, "failed to create UEFI credential")
return cutil.NewAPIErrorResponse(c, apiErr.Code, apiErr.Message, nil)
}

return c.JSON(http.StatusCreated, apiReq.ToResponse())
}
146 changes: 146 additions & 0 deletions rest-api/api/pkg/api/handler/ueficredential_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package handler

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
tmocks "go.temporal.io/sdk/mocks"
"google.golang.org/protobuf/encoding/protojson"

"github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/handler/util/common"
"github.com/NVIDIA/infra-controller/rest-api/api/pkg/api/model"
sc "github.com/NVIDIA/infra-controller/rest-api/api/pkg/client/site"
authz "github.com/NVIDIA/infra-controller/rest-api/auth/pkg/authorization"
"github.com/NVIDIA/infra-controller/rest-api/common/pkg/coreproxy"
cutil "github.com/NVIDIA/infra-controller/rest-api/common/pkg/util"
cdbm "github.com/NVIDIA/infra-controller/rest-api/db/pkg/db/model"
corev1 "github.com/NVIDIA/infra-controller/rest-api/proto/core/gen/v1"
)

func TestCreateUEFICredentialHandlerProxiesCredential(t *testing.T) {
for _, tc := range []struct {
kind model.UEFICredentialKind
credentialType corev1.CredentialType
}{
{model.UEFICredentialKindHost, corev1.CredentialType_HostUefi},
{model.UEFICredentialKindDPU, corev1.CredentialType_DpuUefi},
} {
t.Run(string(tc.kind), func(t *testing.T) {
fixture := newUEFICredentialHandlerFixture(t)
rec, proxiedReq := fixture.request(t, model.APIUEFICredentialRequest{
SiteID: fixture.siteID,
Kind: tc.kind,
Password: "secret-password",
})
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Equal(t, createCredentialMethod, proxiedReq.FullMethod)
assert.NotContains(t, string(proxiedReq.RequestJSON), "secret-password")
assert.NotEmpty(t, proxiedReq.EncryptedSecrets)

var coreReq corev1.CredentialCreationRequest
require.NoError(t, protojson.Unmarshal(proxiedReq.RequestJSON, &coreReq))
assert.Equal(t, tc.credentialType, coreReq.GetCredentialType())
assert.Equal(t, coreproxy.RedactedPlaceholder, coreReq.GetPassword())

var resp model.APIUEFICredential
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, fixture.siteID, resp.SiteID)
assert.Equal(t, tc.kind, resp.Kind)
assert.NotContains(t, rec.Body.String(), "password")
})
}
}

func TestCreateUEFICredentialHandlerRejectsInvalidRequest(t *testing.T) {
fixture := newUEFICredentialHandlerFixture(t)
rec, _ := fixture.request(t, model.APIUEFICredentialRequest{
SiteID: fixture.siteID,
Kind: model.UEFICredentialKind("invalid"),
Password: "secret-password",
})
assert.Equal(t, http.StatusBadRequest, rec.Code)
}

type uefiCredentialHandlerFixture struct {
org string
siteID string
user interface{}
handler CreateUEFICredentialHandler
proxiedReq *coreproxy.Request
}

func newUEFICredentialHandlerFixture(t *testing.T) uefiCredentialHandlerFixture {
t.Helper()

dbSession := common.TestInitDB(t)
t.Cleanup(dbSession.Close)
common.TestSetupSchema(t, dbSession)

org := "test-org"
user := common.TestBuildUser(t, dbSession, "test-starfleet-id", org, []string{authz.ProviderAdminRole})
ip := common.TestBuildInfrastructureProvider(t, dbSession, "Test Infrastructure Provider", org, user)
site := common.TestBuildSite(t, dbSession, ip, "Test Site", user)
sDAO := cdbm.NewSiteDAO(dbSession)
_, err := sDAO.Update(context.Background(), nil, cdbm.SiteUpdateInput{
SiteID: site.ID,
Status: cutil.GetPtr(cdbm.SiteStatusRegistered),
})
require.NoError(t, err)

proxiedReq := &coreproxy.Request{}
wrun := &tmocks.WorkflowRun{}
wrun.On("Get", mock.Anything, mock.Anything).Return(nil)

tsc := &tmocks.Client{}
tsc.On(
"ExecuteWorkflow",
mock.Anything,
mock.Anything,
coreproxy.WorkflowName,
mock.MatchedBy(func(req coreproxy.Request) bool {
*proxiedReq = req
return true
}),
).Return(wrun, nil)

scp := sc.NewClientPool(nil)
scp.IDClientMap[site.ID.String()] = tsc

return uefiCredentialHandlerFixture{
org: org,
siteID: site.ID.String(),
user: user,
handler: NewCreateUEFICredentialHandler(dbSession, scp),
proxiedReq: proxiedReq,
}
}

func (f uefiCredentialHandlerFixture) request(t *testing.T, apiReq model.APIUEFICredentialRequest) (*httptest.ResponseRecorder, coreproxy.Request) {
t.Helper()

body, err := json.Marshal(apiReq)
require.NoError(t, err)

e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
ec := e.NewContext(req, rec)
ec.SetParamNames("orgName")
ec.SetParamValues(f.org)
ec.Set("user", f.user)

require.NoError(t, f.handler.Handle(ec))
return rec, *f.proxiedReq
}
76 changes: 76 additions & 0 deletions rest-api/api/pkg/api/model/ueficredential.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package model

import (
validation "github.com/go-ozzo/ozzo-validation/v4"
validationis "github.com/go-ozzo/ozzo-validation/v4/is"

corev1 "github.com/NVIDIA/infra-controller/rest-api/proto/core/gen/v1"
)

// UEFICredentialKind selects the site-default UEFI credential to create.
type UEFICredentialKind string

const (
// UEFICredentialKindHost creates the site-default host UEFI credential.
UEFICredentialKindHost UEFICredentialKind = "Host"
// UEFICredentialKindDPU creates the site-default DPU UEFI credential.
UEFICredentialKindDPU UEFICredentialKind = "DPU"
)

// APIUEFICredentialRequest creates a site-default UEFI credential.
type APIUEFICredentialRequest struct {
// SiteID is the ID of the Site where the credential is stored.
SiteID string `json:"siteId"`
// Kind selects the host or DPU UEFI credential.
Kind UEFICredentialKind `json:"kind"`
// Password is the credential password.
Password string `json:"password"`
}

// APIUEFICredential is the UEFI credential response with the password omitted.
type APIUEFICredential struct {
// SiteID is the ID of the Site where the credential is stored.
SiteID string `json:"siteId"`
// Kind identifies the UEFI credential that was stored.
Kind UEFICredentialKind `json:"kind"`
}

// Validate checks the request before it is converted to a proto.
func (r *APIUEFICredentialRequest) Validate() error {
if err := validation.ValidateStruct(r,
validation.Field(&r.SiteID,
validation.Required.Error(validationErrorValueRequired),
validationis.UUID.Error(validationErrorInvalidUUID)),
validation.Field(&r.Kind,
validation.Required.Error(validationErrorValueRequired),
validation.In(UEFICredentialKindHost, UEFICredentialKindDPU).Error("invalid kind (expected \"Host\" or \"DPU\")")),
validation.Field(&r.Password,
validation.Required.Error("password is required")),
); err != nil {
return err
}
return nil
}

// ToProto converts the validated request into a CredentialCreationRequest.
func (r *APIUEFICredentialRequest) ToProto() *corev1.CredentialCreationRequest {
credentialType := corev1.CredentialType_HostUefi
if r.Kind == UEFICredentialKindDPU {
credentialType = corev1.CredentialType_DpuUefi
}
return &corev1.CredentialCreationRequest{
CredentialType: credentialType,
Password: r.Password,
}
}

// ToResponse returns the accepted request data without the credential password.
func (r *APIUEFICredentialRequest) ToResponse() *APIUEFICredential {
return &APIUEFICredential{
SiteID: r.SiteID,
Kind: r.Kind,
}
}
75 changes: 75 additions & 0 deletions rest-api/api/pkg/api/model/ueficredential_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package model

import (
"encoding/json"
"testing"

"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

corev1 "github.com/NVIDIA/infra-controller/rest-api/proto/core/gen/v1"
)

func TestAPIUEFICredentialRequestValidate(t *testing.T) {
siteID := uuid.NewString()
cases := []struct {
name string
req APIUEFICredentialRequest
wantErr bool
}{
{"Host ok", APIUEFICredentialRequest{SiteID: siteID, Kind: UEFICredentialKindHost, Password: "pw"}, false},
{"DPU ok", APIUEFICredentialRequest{SiteID: siteID, Kind: UEFICredentialKindDPU, Password: "pw"}, false},
{"missing siteId", APIUEFICredentialRequest{Kind: UEFICredentialKindHost, Password: "pw"}, true},
{"invalid siteId", APIUEFICredentialRequest{SiteID: "bad-site-id", Kind: UEFICredentialKindHost, Password: "pw"}, true},
{"missing password", APIUEFICredentialRequest{SiteID: siteID, Kind: UEFICredentialKindHost}, true},
{"invalid kind", APIUEFICredentialRequest{SiteID: siteID, Kind: UEFICredentialKind("nope"), Password: "pw"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.req.Validate()
if tc.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

func TestAPIUEFICredentialRequestToProto(t *testing.T) {
for _, tc := range []struct {
kind UEFICredentialKind
credentialType corev1.CredentialType
}{
{UEFICredentialKindHost, corev1.CredentialType_HostUefi},
{UEFICredentialKindDPU, corev1.CredentialType_DpuUefi},
} {
t.Run(string(tc.kind), func(t *testing.T) {
req := APIUEFICredentialRequest{Kind: tc.kind, Password: "pw"}
p := req.ToProto()
assert.Equal(t, tc.credentialType, p.GetCredentialType())
assert.Equal(t, "pw", p.GetPassword())
})
}
}

func TestAPIUEFICredentialRequestToResponseOmitsPassword(t *testing.T) {
req := APIUEFICredentialRequest{
SiteID: uuid.NewString(),
Kind: UEFICredentialKindHost,
Password: "pw",
}

resp := req.ToResponse()
assert.Equal(t, req.SiteID, resp.SiteID)
assert.Equal(t, req.Kind, resp.Kind)

body, err := json.Marshal(resp)
require.NoError(t, err)
assert.NotContains(t, string(body), "password")
assert.NotContains(t, string(body), req.Password)
}
7 changes: 7 additions & 0 deletions rest-api/api/pkg/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ func NewAPIRoutes(dbSession *cdb.Session, tc tClient.Client, tnc tClient.Namespa
Method: http.MethodPut,
Handler: apiHandler.NewCreateOrUpdateBMCCredentialHandler(dbSession, scp, cfg),
},
// Site-default UEFI credential endpoint (Provider Admin); equivalent to
// the admin CLI `credential add-uefi` command.
{
Path: apiPathPrefix + "/credential/uefi",
Method: http.MethodPost,
Handler: apiHandler.NewCreateUEFICredentialHandler(dbSession, scp),
},
// User endpoint
{
Path: apiPathPrefix + "/user/current",
Expand Down
Loading
Loading