Skip to content

Commit 45c45c4

Browse files
committed
enforce site-scoped effective TargetedInstanceCreation on instance, machine, and vpc actions
1 parent 9114e1f commit 45c45c4

8 files changed

Lines changed: 187 additions & 13 deletions

File tree

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

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,18 @@ func (cih CreateInstanceHandler) Handle(c echo.Context) error {
861861
return cutil.NewAPIError(http.StatusBadRequest, fmt.Sprintf("Machine specified in request does not belong to Site: %s", site.Name), nil)
862862
}
863863

864+
// Site-scoped gate: the targetedInstanceCreation capability must be
865+
// effective for this Machine's Site (ceiling + per-site opt-in).
866+
enabledForSite, derr := common.EffectiveTargetedInstanceCreation(ctx, tx, cih.dbSession, tenant, machine.SiteID)
867+
if derr != nil {
868+
logger.Error().Err(derr).Msg("error checking effective targeted instance creation for Machine's Site")
869+
return cutil.NewAPIError(http.StatusInternalServerError, "Failed to verify capability for Machine's Site", nil)
870+
}
871+
if !enabledForSite {
872+
logger.Warn().Msg("tenant does not have targeted instance creation enabled for the Machine's Site")
873+
return cutil.NewAPIError(http.StatusForbidden, "Tenant does not have capability to create Instances using specific Machine ID for this Site", nil)
874+
}
875+
864876
// Validate Machine availability. Note: allowUnhealthyMachine also bypasses
865877
// the Ready status check, not just health - consider renaming the parameter later.
866878
if apiRequest.AllowUnhealthyMachine != nil {
@@ -4741,16 +4753,37 @@ func (dih DeleteInstanceHandler) Handle(c echo.Context) error {
47414753
// requires the tenant to carry the TargetedInstanceCreation
47424754
// capability. By the time `ToProto` runs the request is safe
47434755
// to trust.
4756+
// Prepare the delete/release request workflow object
4757+
releaseInstanceRequest := apiRequest.ToProto(instance)
4758+
4759+
// This is for enhanced break-fix flow:
4760+
if apiRequest.MachineHealthIssue != nil {
4761+
releaseInstanceRequest.Issue = &cwssaws.Issue{
4762+
Category: cwssaws.IssueCategory(model.MachineIssueCategoriesFromAPIToProtobuf[apiRequest.MachineHealthIssue.Category]),
4763+
}
4764+
if apiRequest.MachineHealthIssue.Summary != nil {
4765+
releaseInstanceRequest.Issue.Summary = *apiRequest.MachineHealthIssue.Summary
4766+
}
4767+
if apiRequest.MachineHealthIssue.Details != nil {
4768+
releaseInstanceRequest.Issue.Details = *apiRequest.MachineHealthIssue.Details
4769+
}
4770+
}
4771+
4772+
// if caller attempt to set IsRepairTenant then the tenant must have the
4773+
// targetedInstanceCreation capability effective for the Instance's Site.
47444774
if apiRequest.IsRepairTenant != nil && *apiRequest.IsRepairTenant {
4745-
if !common.TenantHasTargetedInstanceCreation(instance.Tenant) {
4746-
logger.Warn().Msg("tenant does not have capability to set IsRepairTenant")
4775+
enabledForSite, derr := common.EffectiveTargetedInstanceCreation(ctx, tx, dih.dbSession, instance.Tenant, instance.SiteID)
4776+
if derr != nil {
4777+
logger.Error().Err(derr).Msg("error checking effective targeted instance creation for Instance's Site")
4778+
return cutil.NewAPIError(http.StatusInternalServerError, "Failed to verify capability for Instance's Site", nil)
4779+
}
4780+
if !enabledForSite {
4781+
logger.Warn().Msg("tenant does not have capability to set IsRepairTenant for the Instance's Site")
47474782
return cutil.NewAPIError(http.StatusForbidden, "Tenant does not have capability to set IsRepairTenant", nil)
47484783
}
47494784
}
47504785

4751-
// Prepare the delete/release request workflow object
4752-
releaseInstanceRequest := apiRequest.ToProto(instance)
4753-
4786+
// Trigger Site workflow to delete instance
47544787
workflowOptions := temporalClient.StartWorkflowOptions{
47554788
ID: "instance-delete-" + instance.ID.String(),
47564789
TaskQueue: queue.SiteTaskQueue,

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ func testInstanceSetupSchema(t *testing.T, dbSession *cdb.Session) {
133133
// create NetworkSecurityGroup table
134134
err = dbSession.DB.ResetModel(context.Background(), (*cdbm.NetworkSecurityGroup)(nil))
135135
assert.Nil(t, err)
136+
// create TenantSiteCapabilityAssociation table
137+
err = dbSession.DB.ResetModel(context.Background(), (*cdbm.TenantSiteCapabilityAssociation)(nil))
138+
assert.Nil(t, err)
136139
}
137140

138141
func testInstanceSiteBuildInfrastructureProvider(t *testing.T, dbSession *cdb.Session, name string, org string, user *cdbm.User) *cdbm.InfrastructureProvider {
@@ -693,6 +696,11 @@ func TestCreateInstanceHandler_Handle(t *testing.T) {
693696
ts1 := testBuildTenantSiteAssociation(t, dbSession, tnOrg, tn1.ID, st1.ID, tnu1.ID)
694697
assert.NotNil(t, ts1)
695698

699+
// Enable the TargetedInstanceCreation capability for the privileged tenant on its sites
700+
// so the site-scoped effective check passes for targeted (MachineID) instance creation.
701+
cdbm.TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn1, st1, true, tnu1)
702+
cdbm.TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn1, st2, true, tnu1)
703+
696704
al1 := testInstanceSiteBuildAllocation(t, dbSession, st1, tn1, "test-allocation-1", ipu)
697705
assert.NotNil(t, al1)
698706

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -757,8 +757,13 @@ func (umh UpdateMachineHandler) Handle(c echo.Context) error {
757757

758758
// Validate if Tenant is allowed to update Machine
759759
if tenant != nil {
760-
// Check if Tenant is privileged
761-
if common.TenantHasTargetedInstanceCreation(tenant) {
760+
// Check if the capability is effective for the Machine's Site (ceiling + per-site opt-in)
761+
enabledForSite, serr := common.EffectiveTargetedInstanceCreation(ctx, nil, umh.dbSession, tenant, machine.SiteID)
762+
if serr != nil {
763+
logger.Error().Err(serr).Msg("error checking effective targeted instance creation for Machine's Site")
764+
return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Error verifying capability for Machine's Site, DB error", nil)
765+
}
766+
if enabledForSite {
762767
// Check if privileged Tenant has an account with Infrastructure Provider
763768
taDAO := cdbm.NewTenantAccountDAO(umh.dbSession)
764769
_, taCount, serr := taDAO.GetAll(ctx, nil, cdbm.TenantAccountFilterInput{

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1635,6 +1635,9 @@ func TestMachineHandler_Update(t *testing.T) {
16351635
tenant2 := testMachineBuildTenant(t, dbSession, tnOrg2, "testTenant2")
16361636
_ = testMachineUpdateTenantCapability(t, dbSession, tenant2)
16371637
_ = common.TestBuildTenantAccount(t, dbSession, ip, &tenant2.ID, tnOrg2, cdbm.TenantAccountStatusReady, tnu2)
1638+
// Enable the TargetedInstanceCreation capability for the privileged tenant on the Machine's Site
1639+
// so the site-scoped effective check passes during Machine update.
1640+
cdbm.TestBuildTenantSiteCapabilityAssociation(t, dbSession, tenant2, site, true, tnu2)
16381641

16391642
instanceType1 := testMachineBuildInstanceType(t, dbSession, ip, site, "testInstanceType1")
16401643
instanceType2 := testMachineBuildInstanceType(t, dbSession, ip, site, "testInstanceType2")

rest-api/api/pkg/api/handler/util/common/common.go

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1284,14 +1284,47 @@ func IsTenant(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session
12841284
// "ceiling"). It is nil-safe so callers don't have to repeat the
12851285
// tenant/Config nil checks.
12861286
//
1287-
// This is the single chokepoint for the tenant-level capability read. A
1288-
// later phase will introduce a site-scoped EffectiveTargetedInstanceCreation
1289-
// that composes this ceiling with per-site association rows and the provider
1290-
// gate; until then this preserves the existing tenant-global behavior.
1287+
// This is the single chokepoint for the tenant-level (ceiling) capability read.
1288+
// It still governs broad discovery/listing paths (e.g. expanding a privileged
1289+
// tenant's visible Sites). Site-scoped capability *actions* should instead use
1290+
// EffectiveTargetedInstanceCreation, which layers the per-site opt-in on top of
1291+
// this ceiling.
12911292
func TenantHasTargetedInstanceCreation(tenant *cdbm.Tenant) bool {
12921293
return tenant != nil && tenant.Config != nil && tenant.Config.TargetedInstanceCreation
12931294
}
12941295

1296+
// EffectiveTargetedInstanceCreation reports whether the TargetedInstanceCreation
1297+
// capability is effective for a Tenant on a specific Site. It composes the
1298+
// tenant-level ceiling (TenantHasTargetedInstanceCreation) with the per-site
1299+
// opt-in stored in TenantSiteCapabilityAssociation:
1300+
//
1301+
// effective = ceiling AND association_enabled(tenant, site)
1302+
//
1303+
// The provider gate (a Ready TenantAccount with the Site's Infrastructure
1304+
// Provider) is intentionally NOT enforced here. Call sites that require it
1305+
// already perform that check where the API mandates it, so keeping it out of
1306+
// this helper avoids double-enforcement and keeps the helper composable.
1307+
//
1308+
// The default when no association row exists is strict (false). The Phase-1
1309+
// conservative backfill materialized enabled rows for previously-implicit
1310+
// Sites, so existing privileged tenants are unaffected.
1311+
func EffectiveTargetedInstanceCreation(ctx context.Context, tx *cdb.Tx, dbSession *cdb.Session, tenant *cdbm.Tenant, siteID uuid.UUID) (bool, error) {
1312+
if !TenantHasTargetedInstanceCreation(tenant) {
1313+
return false, nil
1314+
}
1315+
1316+
tscaDAO := cdbm.NewTenantSiteCapabilityAssociationDAO(dbSession)
1317+
tsca, err := tscaDAO.GetByTenantIDAndSiteID(ctx, tx, tenant.ID, siteID, nil)
1318+
if err != nil {
1319+
if errors.Is(err, cdb.ErrDoesNotExist) {
1320+
return false, nil
1321+
}
1322+
return false, err
1323+
}
1324+
1325+
return tsca.TargetedInstanceCreation, nil
1326+
}
1327+
12951328
// IsProviderOrTenant ensures that user is authorized to act as a Provider Admin or/and Tenant Admin for the org.
12961329
// if authorized it returns the tenant otherwise a relevant error.
12971330
func IsProviderOrTenant(ctx context.Context, logger zerolog.Logger, dbSession *cdb.Session, org string, user *cdbm.User, allowViewerRole bool, requirePrivilegedTenant bool) (infrastructureProvider *cdbm.InfrastructureProvider, tenant *cdbm.Tenant, apiError *cutil.APIError) {

rest-api/api/pkg/api/handler/util/common/common_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2631,3 +2631,80 @@ func TestTenantHasTargetedInstanceCreation(t *testing.T) {
26312631
})
26322632
}
26332633
}
2634+
2635+
func TestEffectiveTargetedInstanceCreation(t *testing.T) {
2636+
ctx := context.Background()
2637+
dbSession := testCommonInitDB(t)
2638+
defer dbSession.Close()
2639+
2640+
testCommonSetupSchema(t, dbSession)
2641+
err := dbSession.DB.ResetModel(ctx, (*cdbm.TenantSiteCapabilityAssociation)(nil))
2642+
assert.Nil(t, err)
2643+
2644+
org := "test-eff-org"
2645+
user := testCommonBuildUser(t, dbSession, uuid.NewString(), []string{org}, []string{authz.TenantAdminRole})
2646+
ip := testCommonBuildInfrastructureProvider(t, dbSession, "Test Provider", org, user)
2647+
site := testCommonBuildSite(t, dbSession, ip, "Test Site", user)
2648+
2649+
// Tenant with the ceiling enabled.
2650+
tnDAO := cdbm.NewTenantDAO(dbSession)
2651+
tenantWithCeiling, err := tnDAO.CreateFromParams(ctx, nil, "ceiling-tenant", cdb.GetStrPtr("Ceiling Tenant"), org, nil,
2652+
&cdbm.TenantConfig{TargetedInstanceCreation: true}, user)
2653+
assert.Nil(t, err)
2654+
// Tenant without the ceiling.
2655+
tenantNoCeiling := testCommonBuildTenant(t, dbSession, "no-ceiling-tenant", org+"-2", user)
2656+
2657+
// Association row for the ceiling tenant on the site, enabled.
2658+
cdbm.TestBuildTenantSiteCapabilityAssociation(t, dbSession, tenantWithCeiling, site, true, user)
2659+
// A disabled association on a second site.
2660+
site2 := testCommonBuildSite(t, dbSession, ip, "Test Site 2", user)
2661+
cdbm.TestBuildTenantSiteCapabilityAssociation(t, dbSession, tenantWithCeiling, site2, false, user)
2662+
// A site with no association row at all.
2663+
site3 := testCommonBuildSite(t, dbSession, ip, "Test Site 3", user)
2664+
2665+
tests := []struct {
2666+
name string
2667+
tenant *cdbm.Tenant
2668+
siteID uuid.UUID
2669+
expected bool
2670+
}{
2671+
{
2672+
name: "nil tenant",
2673+
tenant: nil,
2674+
siteID: site.ID,
2675+
expected: false,
2676+
},
2677+
{
2678+
name: "ceiling disabled",
2679+
tenant: tenantNoCeiling,
2680+
siteID: site.ID,
2681+
expected: false,
2682+
},
2683+
{
2684+
name: "ceiling enabled and association enabled",
2685+
tenant: tenantWithCeiling,
2686+
siteID: site.ID,
2687+
expected: true,
2688+
},
2689+
{
2690+
name: "ceiling enabled but association disabled",
2691+
tenant: tenantWithCeiling,
2692+
siteID: site2.ID,
2693+
expected: false,
2694+
},
2695+
{
2696+
name: "ceiling enabled but no association row",
2697+
tenant: tenantWithCeiling,
2698+
siteID: site3.ID,
2699+
expected: false,
2700+
},
2701+
}
2702+
2703+
for _, tc := range tests {
2704+
t.Run(tc.name, func(t *testing.T) {
2705+
got, gerr := EffectiveTargetedInstanceCreation(ctx, nil, dbSession, tc.tenant, tc.siteID)
2706+
assert.Nil(t, gerr)
2707+
assert.Equal(t, tc.expected, got)
2708+
})
2709+
}
2710+
}

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,14 @@ func (cvh CreateVPCHandler) Handle(c echo.Context) error {
239239
var routingProfile *string
240240
if apiRequest.RoutingProfile != nil {
241241
// For now, we gate on TargetedInstanceCreation permission,
242-
// Which implies a "privileged tenant"
243-
if !common.TenantHasTargetedInstanceCreation(tenant) {
242+
// Which implies a "privileged tenant". The capability must be effective
243+
// for the VPC's Site (ceiling + per-site opt-in).
244+
enabledForSite, derr := common.EffectiveTargetedInstanceCreation(ctx, nil, cvh.dbSession, tenant, site.ID)
245+
if derr != nil {
246+
logger.Error().Err(derr).Msg("error checking effective targeted instance creation for Site")
247+
return cutil.NewAPIErrorResponse(c, http.StatusInternalServerError, "Failed to verify privileges for Site", nil)
248+
}
249+
if !enabledForSite {
244250
logger.Warn().Msg("tenant does not have sufficient privileges to set `routingProfile`")
245251
return cutil.NewAPIErrorResponse(c, http.StatusForbidden, "Tenant does not have sufficient privileges to set `routingProfile`", nil)
246252
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ func testVPCSetupSchema(t *testing.T, dbSession *cdb.Session) {
9292
// create VPC table
9393
err = dbSession.DB.ResetModel(context.Background(), (*cdbm.Vpc)(nil))
9494
assert.Nil(t, err)
95+
// create TenantSiteCapabilityAssociation table
96+
err = dbSession.DB.ResetModel(context.Background(), (*cdbm.TenantSiteCapabilityAssociation)(nil))
97+
assert.Nil(t, err)
9598
}
9699

97100
func testVPCSiteBuildInfrastructureProvider(t *testing.T, dbSession *cdb.Session, name string, org string, user *cdbm.User) *cdbm.InfrastructureProvider {
@@ -360,6 +363,12 @@ func TestCreateVPCHandler_Handle(t *testing.T) {
360363
ts1t3 := testBuildTenantSiteAssociation(t, dbSession, tnOrg3, tn3.ID, st1.ID, tnu3.ID)
361364
assert.NotNil(t, ts1t3)
362365

366+
// Enable the TargetedInstanceCreation capability for the privileged tenant on its sites
367+
// so the site-scoped effective check passes for routingProfile.
368+
cdbm.TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn, st1, true, tnu)
369+
cdbm.TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn, st2, true, tnu)
370+
cdbm.TestBuildTenantSiteCapabilityAssociation(t, dbSession, tn, st3, true, tnu)
371+
363372
// NSG for tenant 1 on site 1
364373
nsgTenant1Site1 := testBuildNetworkSecurityGroup(t, dbSession, "test-nsg-1", tn, st1, cdbm.NetworkSecurityGroupStatusReady)
365374
assert.NotNil(t, nsgTenant1Site1)

0 commit comments

Comments
 (0)