Files
ziti/tests/role_attribute_usage_test.go
Paul Lorenz a582fe5d0f Add role-attribute usage queries. Fixes #1593
- adds management API endpoints listing role-attribute usage for identities,
  edge routers, services, and posture checks, reporting per-source counts
  (and optionally ids via withIds) across home-entity collections and the
  policies that reference each attribute
- adds RoleAttributeUsage model with QueryRoleAttributeUsage, mapping each
  RoleAttributeKind to its contributing sources, with all reads in a single
  transaction so counts stay consistent with the attribute list; returns an
  empty result (not a panic) for kinds with no attributes and an error for
  unknown kinds
- adds boltz.SetIndexValueTransform and AddSetIndexWithTransform, letting a
  SetIndex filter and rewrite symbol values without persisting a derived field
- adds derived role-attribute set indexes to the service policy, edge router
  policy, and service edge router policy stores, built from existing role
  fields via a role-attribute-only transform that excludes the #all wildcard
- adds a db migration that backfills the new indexes via each index's own
  CheckIntegrity(fix=true), scoped to the role-attribute indexes only, with a
  per-index summary log, stopping at the first error
- tests the new endpoints via the typed edge-api management client
2026-06-10 12:39:26 -04:00

388 lines
14 KiB
Go

//go:build apitests
/*
Copyright NetFoundry Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package tests
import (
"fmt"
"net/http"
"sort"
"testing"
"github.com/openziti/edge-api/rest_management_api_client/role_attributes"
"github.com/openziti/edge-api/rest_model"
"github.com/openziti/foundation/v2/util"
"github.com/openziti/ziti/v2/common/eid"
)
// usageFilter returns a filter restricting role-attribute usage results to
// attributes carrying the given unique test prefix, sorted by id so result
// order is deterministic.
func usageFilter(prefix string) *string {
return util.Ptr(fmt.Sprintf(`id contains "%s" sort by id`, prefix))
}
// usageByAttr returns the usage detail for the given role attribute, or nil
// if the list doesn't contain it.
func usageByAttr(items rest_model.RoleAttributeUsageList, attr string) *rest_model.RoleAttributeUsageDetail {
for _, item := range items {
if item.RoleAttribute != nil && *item.RoleAttribute == attr {
return item
}
}
return nil
}
// usageFor returns the usage entry for one source on a detail, failing the
// test with a diagnostic if the detail, source entry, or required count is
// missing, so callers can dereference Count without nil checks.
func (ctx *TestContext) usageFor(detail *rest_model.RoleAttributeUsageDetail, source string) rest_model.RoleAttributeSourceUsage {
ctx.T().Helper()
ctx.Req.NotNil(detail, "expected a usage detail for source %s", source)
entry, ok := detail.Usage[source]
ctx.Req.True(ok, "usage map should contain source %s", source)
ctx.Req.NotNil(entry.Count, "count is required on source %s", source)
return entry
}
// sortedIds returns a sorted copy of ids so assertions don't depend on
// server-side ordering.
func sortedIds(ids []string) []string {
result := append([]string(nil), ids...)
sort.Strings(result)
return result
}
func Test_RoleAttributeUsageEndpoints(t *testing.T) {
ctx := NewTestContext(t)
defer ctx.Teardown()
ctx.StartServer()
ctx.RequireAdminManagementApiLogin()
mgmtClient := ctx.NewEdgeManagementApi(nil)
adminCreds := ctx.NewAdminCredentials()
_, err := mgmtClient.Authenticate(adminCreds, nil)
ctx.Req.NoError(err)
t.Run("identity role-attribute usage", func(t *testing.T) {
ctx.testContextChanged(t)
prefix := "idrau-" + eid.New() + "-"
attrBoth := prefix + "both"
attrIdentityOnly := prefix + "id-only"
attrSpOnly := prefix + "sp-only"
attrErpOnly := prefix + "erp-only"
// Identities
idBoth := ctx.AdminManagementSession.requireNewIdentity(false, attrBoth)
idSolo := ctx.AdminManagementSession.requireNewIdentity(false, attrIdentityOnly)
// Service policies referencing identity role attributes
sp1 := ctx.AdminManagementSession.requireNewServicePolicy(
"Dial",
s("#all"),
s("#"+attrBoth, "#"+attrSpOnly),
s(),
)
sp2 := ctx.AdminManagementSession.requireNewServicePolicy(
"Bind",
s("#all"),
s("#"+attrSpOnly),
s(),
)
// Edge-router policy referencing identity role attributes
erp := ctx.AdminManagementSession.requireNewEdgeRouterPolicy(
s("#all"),
s("#"+attrBoth, "#"+attrErpOnly),
)
t.Run("default (no withIds) returns all four attributes with accurate counts", func(t *testing.T) {
ctx.testContextChanged(t)
resp, err := mgmtClient.API.RoleAttributes.ListIdentityRoleAttributeUsage(&role_attributes.ListIdentityRoleAttributeUsageParams{
Filter: usageFilter(prefix),
}, nil)
ctx.Req.NoError(err)
ctx.Req.NotNil(resp.Payload.Meta, "list responses must carry a meta section")
items := resp.Payload.Data
ctx.Req.Len(items, 4)
// items are sorted by roleAttribute ascending due to our filter's sort clause
expectedOrder := []string{attrBoth, attrErpOnly, attrIdentityOnly, attrSpOnly}
sort.Strings(expectedOrder)
for i, want := range expectedOrder {
ctx.Req.Equal(want, *items[i].RoleAttribute, "at index %d", i)
}
both := usageByAttr(items, attrBoth)
ctx.Req.EqualValues(1, *ctx.usageFor(both, "identities").Count)
ctx.Req.Nil(ctx.usageFor(both, "identities").Ids, "ids should be null when withIds is unset")
ctx.Req.EqualValues(1, *ctx.usageFor(both, "servicePolicies").Count)
ctx.Req.EqualValues(1, *ctx.usageFor(both, "edgeRouterPolicies").Count)
solo := usageByAttr(items, attrIdentityOnly)
ctx.Req.EqualValues(1, *ctx.usageFor(solo, "identities").Count)
ctx.Req.EqualValues(0, *ctx.usageFor(solo, "servicePolicies").Count)
ctx.Req.EqualValues(0, *ctx.usageFor(solo, "edgeRouterPolicies").Count)
spOnly := usageByAttr(items, attrSpOnly)
ctx.Req.EqualValues(0, *ctx.usageFor(spOnly, "identities").Count)
ctx.Req.EqualValues(2, *ctx.usageFor(spOnly, "servicePolicies").Count)
ctx.Req.EqualValues(0, *ctx.usageFor(spOnly, "edgeRouterPolicies").Count)
erpOnly := usageByAttr(items, attrErpOnly)
ctx.Req.EqualValues(0, *ctx.usageFor(erpOnly, "identities").Count)
ctx.Req.EqualValues(0, *ctx.usageFor(erpOnly, "servicePolicies").Count)
ctx.Req.EqualValues(1, *ctx.usageFor(erpOnly, "edgeRouterPolicies").Count)
})
t.Run("withIds=true populates entity id arrays", func(t *testing.T) {
ctx.testContextChanged(t)
resp, err := mgmtClient.API.RoleAttributes.ListIdentityRoleAttributeUsage(&role_attributes.ListIdentityRoleAttributeUsageParams{
Filter: usageFilter(prefix),
WithIds: util.Ptr(true),
}, nil)
ctx.Req.NoError(err)
items := resp.Payload.Data
ctx.Req.Len(items, 4)
both := usageByAttr(items, attrBoth)
ctx.Req.Equal([]string{idBoth.Id}, ctx.usageFor(both, "identities").Ids)
ctx.Req.Equal([]string{sp1.id}, ctx.usageFor(both, "servicePolicies").Ids)
ctx.Req.Equal([]string{erp.id}, ctx.usageFor(both, "edgeRouterPolicies").Ids)
solo := usageByAttr(items, attrIdentityOnly)
ctx.Req.Equal([]string{idSolo.Id}, ctx.usageFor(solo, "identities").Ids)
// When withIds=true, policy sources with count=0 must still emit an
// empty (but present) ids array so callers can distinguish
// "not requested" (null -> nil) from "requested, no matches" ([]).
soloSp := ctx.usageFor(solo, "servicePolicies")
ctx.Req.NotNil(soloSp.Ids, "ids should be [] (present) when withIds=true and count=0")
ctx.Req.Empty(soloSp.Ids)
ctx.Req.EqualValues(0, *soloSp.Count)
spOnly := usageByAttr(items, attrSpOnly)
expectedSp := sortedIds([]string{sp1.id, sp2.id})
ctx.Req.Equal(expectedSp, sortedIds(ctx.usageFor(spOnly, "servicePolicies").Ids))
})
t.Run("filter narrows result set", func(t *testing.T) {
ctx.testContextChanged(t)
filter := fmt.Sprintf(`id contains "%s" and (id contains "both" or id contains "sp-only") sort by id`, prefix)
resp, err := mgmtClient.API.RoleAttributes.ListIdentityRoleAttributeUsage(&role_attributes.ListIdentityRoleAttributeUsageParams{
Filter: util.Ptr(filter),
}, nil)
ctx.Req.NoError(err)
items := resp.Payload.Data
ctx.Req.Len(items, 2)
ctx.Req.Equal(attrBoth, *items[0].RoleAttribute)
ctx.Req.Equal(attrSpOnly, *items[1].RoleAttribute)
})
t.Run("limit and skip apply over the sorted attribute set", func(t *testing.T) {
ctx.testContextChanged(t)
filter := fmt.Sprintf(`id contains "%s" sort by id skip 1 limit 2`, prefix)
resp, err := mgmtClient.API.RoleAttributes.ListIdentityRoleAttributeUsage(&role_attributes.ListIdentityRoleAttributeUsageParams{
Filter: util.Ptr(filter),
}, nil)
ctx.Req.NoError(err)
items := resp.Payload.Data
ctx.Req.Len(items, 2)
all := []string{attrBoth, attrErpOnly, attrIdentityOnly, attrSpOnly}
sort.Strings(all)
ctx.Req.Equal(all[1], *items[0].RoleAttribute)
ctx.Req.Equal(all[2], *items[1].RoleAttribute)
})
})
t.Run("edge router role-attribute usage", func(t *testing.T) {
ctx.testContextChanged(t)
prefix := "errau-" + eid.New() + "-"
attrBoth := prefix + "both"
attrRouterOnly := prefix + "er-only"
attrErpOnly := prefix + "erp-only"
attrSerpOnly := prefix + "serp-only"
er := ctx.AdminManagementSession.requireNewEdgeRouter(attrBoth)
erSolo := ctx.AdminManagementSession.requireNewEdgeRouter(attrRouterOnly)
erp := ctx.AdminManagementSession.requireNewEdgeRouterPolicy(
s("#"+attrBoth, "#"+attrErpOnly),
s("#all"),
)
serp := ctx.AdminManagementSession.requireNewServiceEdgeRouterPolicy(
s("#"+attrSerpOnly),
s("#all"),
)
resp, err := mgmtClient.API.RoleAttributes.ListEdgeRouterRoleAttributeUsage(&role_attributes.ListEdgeRouterRoleAttributeUsageParams{
Filter: usageFilter(prefix),
WithIds: util.Ptr(true),
}, nil)
ctx.Req.NoError(err)
items := resp.Payload.Data
ctx.Req.Len(items, 4)
both := usageByAttr(items, attrBoth)
ctx.Req.Equal([]string{er.id}, ctx.usageFor(both, "edgeRouters").Ids)
ctx.Req.Equal([]string{erp.id}, ctx.usageFor(both, "edgeRouterPolicies").Ids)
ctx.Req.EqualValues(0, *ctx.usageFor(both, "serviceEdgeRouterPolicies").Count)
erOnly := usageByAttr(items, attrRouterOnly)
ctx.Req.Equal([]string{erSolo.id}, ctx.usageFor(erOnly, "edgeRouters").Ids)
ctx.Req.EqualValues(0, *ctx.usageFor(erOnly, "edgeRouterPolicies").Count)
ctx.Req.EqualValues(0, *ctx.usageFor(erOnly, "serviceEdgeRouterPolicies").Count)
erpOnly := usageByAttr(items, attrErpOnly)
ctx.Req.EqualValues(0, *ctx.usageFor(erpOnly, "edgeRouters").Count)
ctx.Req.Equal([]string{erp.id}, ctx.usageFor(erpOnly, "edgeRouterPolicies").Ids)
serpOnly := usageByAttr(items, attrSerpOnly)
ctx.Req.Equal([]string{serp.id}, ctx.usageFor(serpOnly, "serviceEdgeRouterPolicies").Ids)
})
t.Run("service role-attribute usage", func(t *testing.T) {
ctx.testContextChanged(t)
prefix := "srau-" + eid.New() + "-"
attrBoth := prefix + "both"
attrSvcOnly := prefix + "svc-only"
attrSpOnly := prefix + "sp-only"
attrSerpOnly := prefix + "serp-only"
svc := ctx.AdminManagementSession.requireNewService(s(attrBoth), nil)
svcSolo := ctx.AdminManagementSession.requireNewService(s(attrSvcOnly), nil)
sp := ctx.AdminManagementSession.requireNewServicePolicy(
"Dial",
s("#"+attrBoth, "#"+attrSpOnly),
s("#all"),
s(),
)
serp := ctx.AdminManagementSession.requireNewServiceEdgeRouterPolicy(
s("#all"),
s("#"+attrSerpOnly),
)
resp, err := mgmtClient.API.RoleAttributes.ListServiceRoleAttributeUsage(&role_attributes.ListServiceRoleAttributeUsageParams{
Filter: usageFilter(prefix),
WithIds: util.Ptr(true),
}, nil)
ctx.Req.NoError(err)
items := resp.Payload.Data
ctx.Req.Len(items, 4)
both := usageByAttr(items, attrBoth)
ctx.Req.Equal([]string{svc.Id}, ctx.usageFor(both, "services").Ids)
ctx.Req.Equal([]string{sp.id}, ctx.usageFor(both, "servicePolicies").Ids)
ctx.Req.EqualValues(0, *ctx.usageFor(both, "serviceEdgeRouterPolicies").Count)
svcOnly := usageByAttr(items, attrSvcOnly)
ctx.Req.Equal([]string{svcSolo.Id}, ctx.usageFor(svcOnly, "services").Ids)
serpOnly := usageByAttr(items, attrSerpOnly)
ctx.Req.Equal([]string{serp.id}, ctx.usageFor(serpOnly, "serviceEdgeRouterPolicies").Ids)
})
t.Run("posture check role-attribute usage", func(t *testing.T) {
ctx.testContextChanged(t)
prefix := "pcrau-" + eid.New() + "-"
attrBoth := prefix + "both"
attrPostureOnly := prefix + "pc-only"
attrSpOnly := prefix + "sp-only"
pc := ctx.AdminManagementSession.requireNewPostureCheckDomain(s("example.com"), s(attrBoth))
pcSolo := ctx.AdminManagementSession.requireNewPostureCheckDomain(s("example.org"), s(attrPostureOnly))
sp := ctx.AdminManagementSession.requireNewServicePolicy(
"Dial",
s("#all"),
s("#all"),
s("#"+attrBoth, "#"+attrSpOnly),
)
resp, err := mgmtClient.API.RoleAttributes.ListPostureCheckRoleAttributeUsage(&role_attributes.ListPostureCheckRoleAttributeUsageParams{
Filter: usageFilter(prefix),
WithIds: util.Ptr(true),
}, nil)
ctx.Req.NoError(err)
items := resp.Payload.Data
ctx.Req.Len(items, 3)
both := usageByAttr(items, attrBoth)
ctx.Req.Equal([]string{pc.id}, ctx.usageFor(both, "postureChecks").Ids)
ctx.Req.Equal([]string{sp.id}, ctx.usageFor(both, "servicePolicies").Ids)
pcOnly := usageByAttr(items, attrPostureOnly)
ctx.Req.Equal([]string{pcSolo.id}, ctx.usageFor(pcOnly, "postureChecks").Ids)
ctx.Req.EqualValues(0, *ctx.usageFor(pcOnly, "servicePolicies").Count)
spOnly := usageByAttr(items, attrSpOnly)
ctx.Req.EqualValues(0, *ctx.usageFor(spOnly, "postureChecks").Count)
ctx.Req.Equal([]string{sp.id}, ctx.usageFor(spOnly, "servicePolicies").Ids)
})
t.Run("usage endpoints require the same management auth as the sibling list endpoints", func(t *testing.T) {
ctx.testContextChanged(t)
// withIds returns cross-entity policy ids, which could tempt a future
// change toward per-source authorization. The intended behavior is that
// usage endpoints carry exactly the same management read permission as
// the existing *-role-attributes list endpoints. Lock that by proving an
// unauthenticated request is rejected identically on both, including the
// withIds variant. If the permission model ever diverges, this fails.
// Raw HTTP is deliberate here: status codes for anonymous requests are
// the point.
pairs := []struct {
usage string
list string
}{
{"identity-role-attribute-usage", "identity-role-attributes"},
{"edge-router-role-attribute-usage", "edge-router-role-attributes"},
{"service-role-attribute-usage", "service-role-attributes"},
{"posture-check-role-attribute-usage", "posture-check-role-attributes"},
}
for _, p := range pairs {
listResp, err := ctx.newAnonymousManagementApiRequest().Get(p.list)
ctx.Req.NoError(err)
ctx.Req.Equal(http.StatusUnauthorized, listResp.StatusCode(),
"baseline: unauthenticated %s should be rejected", p.list)
usageResp, err := ctx.newAnonymousManagementApiRequest().Get(p.usage)
ctx.Req.NoError(err)
ctx.Req.Equal(listResp.StatusCode(), usageResp.StatusCode(),
"%s must require the same auth as %s", p.usage, p.list)
usageIdsResp, err := ctx.newAnonymousManagementApiRequest().Get(p.usage + "?withIds=true")
ctx.Req.NoError(err)
ctx.Req.Equal(listResp.StatusCode(), usageIdsResp.StatusCode(),
"%s?withIds=true must require the same auth as %s; withIds does not relax authorization", p.usage, p.list)
}
})
}