Skip to content

Commit 7d6bcc6

Browse files
committed
feat: add GET tenant capabilities endpoint to read per-site TargetedInstanceCreation scoping
1 parent 168fba3 commit 7d6bcc6

36 files changed

Lines changed: 919 additions & 106 deletions

rest-api/api/pkg/api/handler/tenantcapability.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,121 @@ func resolveTargetSites(requestedSites []string, providerFilter *uuid.UUID, elig
320320

321321
return targets, nil
322322
}
323+
324+
// ~~~~~ Get Handler ~~~~~ //
325+
326+
// GetTenantCapabilityHandler is the API Handler for reading a Tenant's current scoped
327+
// capability configuration (e.g. which Sites have TargetedInstanceCreation enabled).
328+
type GetTenantCapabilityHandler struct {
329+
dbSession *cdb.Session
330+
cfg *config.Config
331+
tracerSpan *cutil.TracerSpan
332+
}
333+
334+
// NewGetTenantCapabilityHandler initializes and returns a new handler for reading tenant capabilities
335+
func NewGetTenantCapabilityHandler(dbSession *cdb.Session, cfg *config.Config) GetTenantCapabilityHandler {
336+
return GetTenantCapabilityHandler{
337+
dbSession: dbSession,
338+
cfg: cfg,
339+
tracerSpan: cutil.NewTracerSpan(),
340+
}
341+
}
342+
343+
// Handle godoc
344+
// @Summary Read scoped tenant capabilities
345+
// @Description Read the current scoping of tenant capabilities (e.g. the Sites TargetedInstanceCreation is enabled for)
346+
// @Tags tenant
347+
// @Produce json
348+
// @Security ApiKeyAuth
349+
// @Param org path string true "Name of NGC organization"
350+
// @Param tenantId path string true "ID of Tenant"
351+
// @Param infrastructureProviderId query string false "Restrict the reported Sites to those owned by this Infrastructure Provider"
352+
// @Success 200 {object} model.APITenantCapabilityListResponse
353+
// @Router /v2/org/{org}/nico/tenant/{tenantId}/capabilities [get]
354+
func (gtch GetTenantCapabilityHandler) Handle(c echo.Context) error {
355+
org, dbUser, ctx, logger, handlerSpan := common.SetupHandler("TenantCapability", "Get", c, gtch.tracerSpan)
356+
if handlerSpan != nil {
357+
defer handlerSpan.End()
358+
}
359+
if dbUser == nil {
360+
return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve current user", nil)
361+
}
362+
363+
// Validate org
364+
ok, err := auth.ValidateOrgMembership(dbUser, org)
365+
if !ok {
366+
if err != nil {
367+
logger.Error().Err(err).Msg("error validating org membership for User in request")
368+
} else {
369+
logger.Warn().Msg("could not validate org membership for user, access denied")
370+
}
371+
return cutil.NewAPIErrorResponse(c, http.StatusForbidden, fmt.Sprintf("Failed to validate membership for org: %s", org), nil)
372+
}
373+
374+
// Validate role, only Tenant Admins may read scoped capabilities for their tenant
375+
ok = auth.ValidateUserRoles(dbUser, org, nil, auth.TenantAdminRole)
376+
if !ok {
377+
logger.Warn().Msg("user does not have Tenant Admin role with org, access denied")
378+
return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "User does not have Tenant Admin role with org", nil)
379+
}
380+
381+
// Get tenant ID from URL param
382+
tenantStrID := c.Param("tenantId")
383+
384+
gtch.tracerSpan.SetAttribute(handlerSpan, attribute.String("tenant_id", tenantStrID), logger)
385+
386+
tenantID, err := uuid.Parse(tenantStrID)
387+
if err != nil {
388+
logger.Warn().Err(err).Msg("error parsing tenantId in url into uuid")
389+
return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Tenant ID in URL", nil)
390+
}
391+
392+
// Resolve the tenant for this org and ensure it matches the tenant in the URL.
393+
tenant, err := common.GetTenantForOrg(ctx, nil, gtch.dbSession, org)
394+
if err != nil {
395+
logger.Warn().Err(err).Msg("tenant does not exist for org")
396+
return cutil.NewAPIErrorResponse(c, http.StatusNotFound, "Org does not have a tenant", nil)
397+
}
398+
if tenant.ID != tenantID {
399+
logger.Warn().Msg("tenant in URL does not match tenant for org")
400+
return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant in URL does not belong to org", nil)
401+
}
402+
403+
// Optional provider filter passed as a query parameter.
404+
filter := cdbm.TenantSiteCapabilityAssociationFilterInput{TenantIDs: []uuid.UUID{tenant.ID}}
405+
if rawProvider := c.QueryParam("infrastructureProviderId"); rawProvider != "" {
406+
pid, perr := uuid.Parse(rawProvider)
407+
if perr != nil {
408+
return cutil.NewAPIErrorResponse(c, http.StatusBadRequest, "Invalid Infrastructure Provider ID in query", nil)
409+
}
410+
filter.InfrastructureProviderIDs = []uuid.UUID{pid}
411+
}
412+
413+
tscaDAO := cdbm.NewTenantSiteCapabilityAssociationDAO(gtch.dbSession)
414+
tscas, _, err := tscaDAO.GetAll(ctx, nil, filter, cdbp.PageInput{Limit: cdb.GetIntPtr(cdbp.TotalLimit)}, nil)
415+
if err != nil {
416+
logger.Error().Err(err).Msg("error retrieving capability associations for tenant")
417+
return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to retrieve Tenant capabilities", nil)
418+
}
419+
420+
enabledSiteIDs := []string{}
421+
for _, tsca := range tscas {
422+
if tsca.TargetedInstanceCreation {
423+
enabledSiteIDs = append(enabledSiteIDs, tsca.SiteID.String())
424+
}
425+
}
426+
427+
apiResponse := model.APITenantCapabilityListResponse{
428+
Capabilities: []model.APITenantCapabilityStatus{
429+
{
430+
CapabilityName: model.CapabilityNameTargetedInstanceCreation,
431+
CeilingEnabled: common.TenantHasTargetedInstanceCreation(tenant),
432+
SiteIDs: enabledSiteIDs,
433+
},
434+
},
435+
}
436+
437+
logger.Info().Msg("finishing API handler")
438+
439+
return c.JSON(http.StatusOK, apiResponse)
440+
}

rest-api/api/pkg/api/handler/tenantcapability_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,3 +339,174 @@ func TestTenantCapabilityHandler_Update(t *testing.T) {
339339
})
340340
}
341341
}
342+
343+
func TestTenantCapabilityHandler_Get(t *testing.T) {
344+
ctx := context.Background()
345+
dbSession := testTenantCapabilityInitDB(t)
346+
defer dbSession.Close()
347+
348+
testTenantCapabilitySetupSchema(t, dbSession)
349+
350+
ipOrg := "test-ip-org"
351+
tnOrg := "test-tn-org"
352+
tnOrgNoCeiling := "test-tn-org-no-ceiling"
353+
tnOrgNoTenant := "test-tn-org-no-tenant"
354+
355+
ipUser := cdbm.TestBuildUser(t, dbSession, uuid.NewString(), ipOrg, []string{authz.ProviderAdminRole})
356+
tnUser := cdbm.TestBuildUser(t, dbSession, uuid.NewString(), tnOrg, []string{authz.TenantAdminRole})
357+
tnUserNoCeiling := cdbm.TestBuildUser(t, dbSession, uuid.NewString(), tnOrgNoCeiling, []string{authz.TenantAdminRole})
358+
tnNonAdminUser := cdbm.TestBuildUser(t, dbSession, uuid.NewString(), tnOrg, []string{authz.ProviderAdminRole})
359+
noTenantUser := cdbm.TestBuildUser(t, dbSession, uuid.NewString(), tnOrgNoTenant, []string{authz.TenantAdminRole})
360+
361+
ip := cdbm.TestBuildInfrastructureProvider(t, dbSession, "Test Provider", ipOrg, ipUser)
362+
ip2 := cdbm.TestBuildInfrastructureProvider(t, dbSession, "Test Provider 2", ipOrg+"-2", ipUser)
363+
364+
tn := testTenantCapabilityBuildTenant(t, dbSession, tnOrg, tnUser, true)
365+
tnNoCeiling := testTenantCapabilityBuildTenant(t, dbSession, tnOrgNoCeiling, tnUserNoCeiling, false)
366+
367+
site1 := cdbm.TestBuildSite(t, dbSession, ip, "Test Site 1", ipUser)
368+
site2 := cdbm.TestBuildSite(t, dbSession, ip, "Test Site 2", ipUser)
369+
site3 := cdbm.TestBuildSite(t, dbSession, ip2, "Test Site 3", ipUser)
370+
371+
// site1 enabled, site2 explicitly disabled, site3 (under ip2) enabled.
372+
cdbm.TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn, site1, true, tnUser)
373+
cdbm.TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn, site2, false, tnUser)
374+
cdbm.TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn, site3, true, tnUser)
375+
376+
cfg := common.GetTestConfig()
377+
tracer, _, ctx := common.TestCommonTraceProviderSetup(t, ctx)
378+
379+
tests := []struct {
380+
name string
381+
reqOrgName string
382+
tenantID string
383+
providerFilter string
384+
user *cdbm.User
385+
expectedStatus int
386+
expectedCeiling bool
387+
expectedSiteIDs []string
388+
verifyChildSpanner bool
389+
}{
390+
{
391+
name: "error when user not found in request context",
392+
reqOrgName: tnOrg,
393+
tenantID: tn.ID.String(),
394+
user: nil,
395+
expectedStatus: http.StatusInternalServerError,
396+
},
397+
{
398+
name: "error when user is not a tenant admin",
399+
reqOrgName: tnOrg,
400+
tenantID: tn.ID.String(),
401+
user: tnNonAdminUser,
402+
expectedStatus: http.StatusForbidden,
403+
},
404+
{
405+
name: "error when tenantId in URL is not a valid uuid",
406+
reqOrgName: tnOrg,
407+
tenantID: "not-a-uuid",
408+
user: tnUser,
409+
expectedStatus: http.StatusBadRequest,
410+
},
411+
{
412+
name: "error when provider filter is not a valid uuid",
413+
reqOrgName: tnOrg,
414+
tenantID: tn.ID.String(),
415+
providerFilter: "not-a-uuid",
416+
user: tnUser,
417+
expectedStatus: http.StatusBadRequest,
418+
},
419+
{
420+
name: "error when org has no tenant",
421+
reqOrgName: tnOrgNoTenant,
422+
tenantID: tn.ID.String(),
423+
user: noTenantUser,
424+
expectedStatus: http.StatusNotFound,
425+
},
426+
{
427+
name: "error when tenant in URL does not match org tenant",
428+
reqOrgName: tnOrg,
429+
tenantID: uuid.New().String(),
430+
user: tnUser,
431+
expectedStatus: http.StatusForbidden,
432+
},
433+
{
434+
name: "success lists only enabled sites",
435+
reqOrgName: tnOrg,
436+
tenantID: tn.ID.String(),
437+
user: tnUser,
438+
expectedStatus: http.StatusOK,
439+
expectedCeiling: true,
440+
expectedSiteIDs: []string{site1.ID.String(), site3.ID.String()},
441+
verifyChildSpanner: true,
442+
},
443+
{
444+
name: "success filters enabled sites by provider",
445+
reqOrgName: tnOrg,
446+
tenantID: tn.ID.String(),
447+
providerFilter: ip.ID.String(),
448+
user: tnUser,
449+
expectedStatus: http.StatusOK,
450+
expectedCeiling: true,
451+
expectedSiteIDs: []string{site1.ID.String()},
452+
},
453+
{
454+
name: "success reports ceiling disabled and no sites",
455+
reqOrgName: tnOrgNoCeiling,
456+
tenantID: tnNoCeiling.ID.String(),
457+
user: tnUserNoCeiling,
458+
expectedStatus: http.StatusOK,
459+
expectedCeiling: false,
460+
expectedSiteIDs: []string{},
461+
},
462+
}
463+
for _, tc := range tests {
464+
t.Run(tc.name, func(t *testing.T) {
465+
e := echo.New()
466+
target := "/"
467+
if tc.providerFilter != "" {
468+
target = "/?infrastructureProviderId=" + tc.providerFilter
469+
}
470+
req := httptest.NewRequest(http.MethodGet, target, nil)
471+
rec := httptest.NewRecorder()
472+
473+
ec := e.NewContext(req, rec)
474+
ec.SetParamNames("orgName", "tenantId")
475+
ec.SetParamValues(tc.reqOrgName, tc.tenantID)
476+
if tc.user != nil {
477+
ec.Set("user", tc.user)
478+
}
479+
480+
ctx = context.WithValue(ctx, otelecho.TracerKey, tracer)
481+
ec.SetRequest(ec.Request().WithContext(ctx))
482+
483+
h := GetTenantCapabilityHandler{
484+
dbSession: dbSession,
485+
cfg: cfg,
486+
}
487+
err := h.Handle(ec)
488+
assert.Nil(t, err)
489+
490+
if tc.expectedStatus != rec.Code {
491+
t.Errorf("response: %v\n", rec.Body.String())
492+
}
493+
require.Equal(t, tc.expectedStatus, rec.Code)
494+
495+
if tc.expectedStatus == http.StatusOK {
496+
rsp := &model.APITenantCapabilityListResponse{}
497+
uerr := json.Unmarshal(rec.Body.Bytes(), rsp)
498+
assert.Nil(t, uerr)
499+
require.Len(t, rsp.Capabilities, 1)
500+
capStatus := rsp.Capabilities[0]
501+
assert.Equal(t, model.CapabilityNameTargetedInstanceCreation, capStatus.CapabilityName)
502+
assert.Equal(t, tc.expectedCeiling, capStatus.CeilingEnabled)
503+
assert.ElementsMatch(t, tc.expectedSiteIDs, capStatus.SiteIDs)
504+
}
505+
506+
if tc.verifyChildSpanner {
507+
span := oteltrace.SpanFromContext(ec.Request().Context())
508+
assert.True(t, span.SpanContext().IsValid())
509+
}
510+
})
511+
}
512+
}

rest-api/api/pkg/api/model/tenantcapability.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,23 @@ type APITenantCapability struct {
6464
// SiteIDs are the Site IDs the change was applied to.
6565
SiteIDs []string `json:"siteIds"`
6666
}
67+
68+
// APITenantCapabilityStatus is the API representation of the current scoping of a single
69+
// capability for a Tenant: whether the tenant-level ceiling is set and the Sites the
70+
// capability is currently enabled for.
71+
type APITenantCapabilityStatus struct {
72+
// CapabilityName is the capability being reported, e.g. "TargetedInstanceCreation".
73+
CapabilityName string `json:"capabilityName"`
74+
// CeilingEnabled reflects the tenant-level entitlement (Tenant.Config). A capability
75+
// can only be effective on a Site when this is true.
76+
CeilingEnabled bool `json:"ceilingEnabled"`
77+
// SiteIDs are the Site IDs the capability is currently enabled for.
78+
SiteIDs []string `json:"siteIds"`
79+
}
80+
81+
// APITenantCapabilityListResponse is the response for reading a Tenant's current scoped
82+
// capabilities. It lists the status of each supported capability.
83+
type APITenantCapabilityListResponse struct {
84+
// Capabilities is the per-capability scoping status for the Tenant.
85+
Capabilities []APITenantCapabilityStatus `json:"capabilities"`
86+
}

rest-api/api/pkg/api/routes.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,17 @@ func NewAPIRoutes(dbSession *cdb.Session, tc tClient.Client, tnc tClient.Namespa
8383
Method: http.MethodGet,
8484
Handler: apiHandler.NewGetCurrentTenantStatsHandler(dbSession, tc, cfg),
8585
},
86-
// Tenant Capability endpoint
86+
// Tenant Capability endpoints
8787
{
8888
Path: apiPathPrefix + "/tenant/:tenantId/capabilities",
8989
Method: http.MethodPatch,
9090
Handler: apiHandler.NewUpdateTenantCapabilityHandler(dbSession, tc, cfg),
9191
},
92+
{
93+
Path: apiPathPrefix + "/tenant/:tenantId/capabilities",
94+
Method: http.MethodGet,
95+
Handler: apiHandler.NewGetTenantCapabilityHandler(dbSession, cfg),
96+
},
9297
// Tenant Instance Type Stats endpoint
9398
{
9499
Path: apiPathPrefix + "/tenant/instance-type/stats",

rest-api/api/pkg/api/routes_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func TestNewAPIRoutes(t *testing.T) {
3737
"metadata": 1,
3838
"service-account": 1,
3939
"infrastructure-provider": 4,
40-
"tenant": 5,
40+
"tenant": 6,
4141
"tenant-account": 5,
4242
"site": 6,
4343
"vpc": 6,

rest-api/flow/internal/task/componentmanager/providers/nico/assignment.go

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,5 @@
1-
/*
2-
* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3-
* SPDX-License-Identifier: Apache-2.0
4-
*
5-
* Licensed under the Apache License, Version 2.0 (the "License");
6-
* you may not use this file except in compliance with the License.
7-
* You may obtain a copy of the License at
8-
*
9-
* http://www.apache.org/licenses/LICENSE-2.0
10-
*
11-
* Unless required by applicable law or agreed to in writing, software
12-
* distributed under the License is distributed on an "AS IS" BASIS,
13-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14-
* See the License for the specific language governing permissions and
15-
* limitations under the License.
16-
*/
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
173

184
package nico
195

rest-api/flow/internal/task/componentmanager/providers/nico/assignment_test.go

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,5 @@
1-
/*
2-
* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3-
* SPDX-License-Identifier: Apache-2.0
4-
*
5-
* Licensed under the Apache License, Version 2.0 (the "License");
6-
* you may not use this file except in compliance with the License.
7-
* You may obtain a copy of the License at
8-
*
9-
* http://www.apache.org/licenses/LICENSE-2.0
10-
*
11-
* Unless required by applicable law or agreed to in writing, software
12-
* distributed under the License is distributed on an "AS IS" BASIS,
13-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14-
* See the License for the specific language governing permissions and
15-
* limitations under the License.
16-
*/
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
173

184
package nico
195

0 commit comments

Comments
 (0)