diff --git a/service/entityresolution/claims/v2/entity_resolution.go b/service/entityresolution/claims/v2/entity_resolution.go index 41003d7ee5..002726b501 100644 --- a/service/entityresolution/claims/v2/entity_resolution.go +++ b/service/entityresolution/claims/v2/entity_resolution.go @@ -2,11 +2,15 @@ package claims import ( "context" + "errors" "fmt" + "log" "log/slog" "strconv" + "strings" "connectrpc.com/connect" + "github.com/go-viper/mapstructure/v2" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/opentdf/platform/protocol/go/entity" entityresolutionV2 "github.com/opentdf/platform/protocol/go/entityresolution/v2" @@ -22,17 +26,30 @@ import ( type EntityResolutionServiceV2 struct { entityresolutionV2.UnimplementedEntityResolutionServiceServer - logger *logger.Logger + logger *logger.Logger + allowDirectEntitlements bool trace.Tracer } -func RegisterClaimsERS(_ config.ServiceConfig, logger *logger.Logger) (EntityResolutionServiceV2, serviceregistry.HandlerServer) { - claimsSVC := EntityResolutionServiceV2{logger: logger} +type Config struct { + AllowDirectEntitlements bool `mapstructure:"allow_direct_entitlements" json:"allow_direct_entitlements" default:"false"` +} + +func RegisterClaimsERS(cfg config.ServiceConfig, logger *logger.Logger) (EntityResolutionServiceV2, serviceregistry.HandlerServer) { + var inputConfig Config + if err := mapstructure.Decode(cfg, &inputConfig); err != nil { + logger.Error("failed to decode claims entity resolution configuration", slog.Any("error", err)) + log.Fatalf("Failed to decode claims entity resolution configuration: %v", err) + } + claimsSVC := EntityResolutionServiceV2{ + logger: logger, + allowDirectEntitlements: inputConfig.AllowDirectEntitlements, + } return claimsSVC, nil } func (s EntityResolutionServiceV2) ResolveEntities(ctx context.Context, req *connect.Request[entityresolutionV2.ResolveEntitiesRequest]) (*connect.Response[entityresolutionV2.ResolveEntitiesResponse], error) { - resp, err := EntityResolution(ctx, req.Msg, s.logger) + resp, err := EntityResolution(ctx, req.Msg, s.logger, s.allowDirectEntitlements) return connect.NewResponse(&resp), err } @@ -63,13 +80,14 @@ func CreateEntityChainsFromTokens( } func EntityResolution(_ context.Context, - req *entityresolutionV2.ResolveEntitiesRequest, logger *logger.Logger, + req *entityresolutionV2.ResolveEntitiesRequest, logger *logger.Logger, allowDirectEntitlements bool, ) (entityresolutionV2.ResolveEntitiesResponse, error) { payload := req.GetEntities() var resolvedEntities []*entityresolutionV2.EntityRepresentation for idx, ident := range payload { entityStruct := &structpb.Struct{} + var directEntitlements []*entityresolutionV2.DirectEntitlement switch ident.GetEntityType().(type) { case *entity.Entity_Claims: claims := ident.GetClaims() @@ -79,6 +97,13 @@ func EntityResolution(_ context.Context, return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("error unpacking anypb.Any to structpb.Struct: %w", err)) } } + if allowDirectEntitlements { + var err error + directEntitlements, err = parseDirectEntitlementsFromClaims(entityStruct) + if err != nil { + return entityresolutionV2.ResolveEntitiesResponse{}, connect.NewError(connect.CodeInvalidArgument, err) + } + } default: retrievedStruct, err := entityToStructPb(ident) if err != nil { @@ -95,8 +120,9 @@ func EntityResolution(_ context.Context, resolvedEntities = append( resolvedEntities, &entityresolutionV2.EntityRepresentation{ - OriginalId: originialID, - AdditionalProps: []*structpb.Struct{entityStruct}, + OriginalId: originialID, + AdditionalProps: []*structpb.Struct{entityStruct}, + DirectEntitlements: directEntitlements, }, ) } @@ -164,3 +190,109 @@ func entityToStructPb(ident *entity.Entity) (*structpb.Struct, error) { } return &entityStruct, nil } + +func parseDirectEntitlementsFromClaims(entityStruct *structpb.Struct) ([]*entityresolutionV2.DirectEntitlement, error) { + if entityStruct == nil { + return nil, nil + } + claims := entityStruct.AsMap() + rawEntitlements, ok := claims["direct_entitlements"] + if !ok { + rawEntitlements, ok = claims["directEntitlements"] + } + if !ok { + return nil, nil + } + + entitlementList, entitlementsOK := rawEntitlements.([]interface{}) + if !entitlementsOK { + return nil, errors.New("direct_entitlements must be an array") + } + + out := make([]*entityresolutionV2.DirectEntitlement, 0, len(entitlementList)) + for idx, entry := range entitlementList { + entryMap, entryOK := entry.(map[string]interface{}) + if !entryOK { + return nil, fmt.Errorf("direct_entitlements[%d] must be an object", idx) + } + + fqn, err := parseDirectEntitlementFQN(entryMap) + if err != nil { + return nil, fmt.Errorf("direct_entitlements[%d] %w", idx, err) + } + + rawActions, actionsOK := entryMap["actions"] + if !actionsOK { + return nil, fmt.Errorf("direct_entitlements[%d] missing actions", idx) + } + actions, err := parseDirectEntitlementActions(rawActions) + if err != nil { + return nil, fmt.Errorf("direct_entitlements[%d] invalid actions: %w", idx, err) + } + + out = append(out, &entityresolutionV2.DirectEntitlement{ + AttributeValueFqn: fqn, + Actions: actions, + }) + } + + return out, nil +} + +func parseDirectEntitlementFQN(entry map[string]interface{}) (string, error) { + if raw, ok := entry["attribute_value_fqn"]; ok { + if fqn, fqnOK := raw.(string); fqnOK { + fqn = strings.TrimSpace(fqn) + if fqn != "" { + return fqn, nil + } + } + } + if raw, ok := entry["attributeValueFqn"]; ok { + if fqn, fqnOK := raw.(string); fqnOK { + fqn = strings.TrimSpace(fqn) + if fqn != "" { + return fqn, nil + } + } + } + return "", errors.New("missing attribute_value_fqn") +} + +func parseDirectEntitlementActions(raw interface{}) ([]string, error) { + actions := make([]string, 0) + switch typed := raw.(type) { + case []interface{}: + for _, action := range typed { + actionStr, ok := action.(string) + if !ok { + return nil, errors.New("action must be a string") + } + actionStr = strings.TrimSpace(strings.ToLower(actionStr)) + if actionStr != "" { + actions = append(actions, actionStr) + } + } + case []string: + for _, action := range typed { + action = strings.TrimSpace(strings.ToLower(action)) + if action != "" { + actions = append(actions, action) + } + } + case string: + for _, action := range strings.Split(typed, ",") { + action = strings.TrimSpace(strings.ToLower(action)) + if action != "" { + actions = append(actions, action) + } + } + default: + return nil, errors.New("actions must be an array or string") + } + + if len(actions) == 0 { + return nil, errors.New("no actions provided") + } + return actions, nil +} diff --git a/service/entityresolution/claims/v2/entity_resolution_test.go b/service/entityresolution/claims/v2/entity_resolution_test.go index 644bdff4e5..9b0fd7f218 100644 --- a/service/entityresolution/claims/v2/entity_resolution_test.go +++ b/service/entityresolution/claims/v2/entity_resolution_test.go @@ -22,7 +22,7 @@ func Test_ClientResolveEntity(t *testing.T) { req := entityresolutionV2.ResolveEntitiesRequest{} req.Entities = validBody - resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger()) + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false) require.NoError(t, reserr) @@ -44,7 +44,7 @@ func Test_EmailResolveEntity(t *testing.T) { req := entityresolutionV2.ResolveEntitiesRequest{} req.Entities = validBody - resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger()) + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false) require.NoError(t, reserr) @@ -78,7 +78,7 @@ func Test_ClaimsResolveEntity(t *testing.T) { req := entityresolutionV2.ResolveEntitiesRequest{} req.Entities = validBody - resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger()) + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false) require.NoError(t, reserr) @@ -93,6 +93,65 @@ func Test_ClaimsResolveEntity(t *testing.T) { assert.EqualValues(t, 42, propMap["baz"]) } +func Test_ClaimsResolveEntityDirectEntitlements(t *testing.T) { + customclaims := map[string]interface{}{ + "direct_entitlements": []interface{}{ + map[string]interface{}{ + "attribute_value_fqn": "https://example.com/attr/department/value/eng", + "actions": []interface{}{"read", "update"}, + }, + }, + } + structClaims, err := structpb.NewStruct(customclaims) + require.NoError(t, err) + + anyClaims, err := anypb.New(structClaims) + require.NoError(t, err) + + var validBody []*entity.Entity + validBody = append(validBody, &entity.Entity{EphemeralId: "1234", EntityType: &entity.Entity_Claims{Claims: anyClaims}}) + + req := entityresolutionV2.ResolveEntitiesRequest{Entities: validBody} + + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), true) + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + require.Len(t, entityRepresentations, 1) + + entitlements := entityRepresentations[0].GetDirectEntitlements() + require.Len(t, entitlements, 1) + assert.Equal(t, "https://example.com/attr/department/value/eng", entitlements[0].GetAttributeValueFqn()) + assert.ElementsMatch(t, []string{"read", "update"}, entitlements[0].GetActions()) +} + +func Test_ClaimsResolveEntityDirectEntitlementsDisabled(t *testing.T) { + customclaims := map[string]interface{}{ + "direct_entitlements": []interface{}{ + map[string]interface{}{ + "attribute_value_fqn": "https://example.com/attr/department/value/eng", + "actions": []interface{}{"read"}, + }, + }, + } + structClaims, err := structpb.NewStruct(customclaims) + require.NoError(t, err) + + anyClaims, err := anypb.New(structClaims) + require.NoError(t, err) + + req := entityresolutionV2.ResolveEntitiesRequest{Entities: []*entity.Entity{ + {EphemeralId: "1234", EntityType: &entity.Entity_Claims{Claims: anyClaims}}, + }} + + resp, reserr := claims.EntityResolution(t.Context(), &req, logger.CreateTestLogger(), false) + require.NoError(t, reserr) + + entityRepresentations := resp.GetEntityRepresentations() + require.Len(t, entityRepresentations, 1) + assert.Empty(t, entityRepresentations[0].GetDirectEntitlements()) +} + func Test_JWTToEntityChainClaims(t *testing.T) { validBody := []*entity.Token{{Jwt: samplejwt}} diff --git a/tests-bdd/cukes/resources/platform.direct_entitlements.template b/tests-bdd/cukes/resources/platform.direct_entitlements.template new file mode 100644 index 0000000000..1c8562909a --- /dev/null +++ b/tests-bdd/cukes/resources/platform.direct_entitlements.template @@ -0,0 +1,51 @@ +authEndpoint: &authEndpoint http://{{ .hostname }}:{{.kcPort }}/auth +issuerEndpoint: &issuerEndpoint http://{{ .hostname }}:{{.kcPort }}/auth/realms/{{.authRealm}} +tokenEndpoint: &tokenEndpoint http://{{ .hostname }}:{{.kcPort }}/auth/realms/{{.authRealm}}/protocol/openid-connect/token +entityResolutionServiceUrl: &entityResolutionServiceUrl https://{{ .hostname }}:{{.platformPort }}/entityresolution/resolve +platformEndpoint: &platformEndpoint https://{{.hostname }}:{{.platformPort }} +authRealm: &authRealm {{.authRealm}} +mode: all +logger: + level: debug + type: text + output: stdout +server: + port: {{.platformPort}} + auth: + enabled: true + enforceDPoP: false + audience: *platformEndpoint + issuer: *issuerEndpoint + policy: + extension: | + g, opentdf-admin, role:admin + g, opentdf-standard, role:standard +db: + host: {{ .pgHost }} + port: {{ .pgPort }} + database: {{ .pgDatabase }} + user: postgres + password: changeme + schema: otdf +services: + authorization: + allow_direct_entitlements: true + kas: + keyring: + - kid: e1 + alg: ec:secp256r1 + - kid: r1 + alg: rsa:2048 + entityresolution: + mode: claims + allow_direct_entitlements: true + shared: + clientId: otdf-shared + clientSecret: secret + authClientId: otdf-shared-auth + serviceHostName: shared + platformEndpoint: *platformEndpoint + platformAuthEndpoint: *authEndpoint + platformAuthRealm: *authRealm + tokenEndpoint: *tokenEndpoint + # ...other service configs as needed... diff --git a/tests-bdd/cukes/steps_authorization.go b/tests-bdd/cukes/steps_authorization.go index 33882d3712..d0e3a36aa9 100644 --- a/tests-bdd/cukes/steps_authorization.go +++ b/tests-bdd/cukes/steps_authorization.go @@ -2,6 +2,7 @@ package cukes import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -12,12 +13,15 @@ import ( "github.com/opentdf/platform/protocol/go/policy" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" ) type AuthorizationServiceStepDefinitions struct{} const ( - decisionResponse = "decisionResponse" + decisionResponse = "decisionResponse" + directEntitlementColumnAttributeFQN = "attribute_value_fqn" + directEntitlementColumnActions = "actions" ) func ConvertInterfaceToAny(jsonData []byte) (*anypb.Any, error) { @@ -103,6 +107,72 @@ func (s *AuthorizationServiceStepDefinitions) thereIsASubjectEntityWithValueAndR return ctx, nil } +func (s *AuthorizationServiceStepDefinitions) thereIsAClaimsSubjectEntityReferencedAsWithDirectEntitlements(ctx context.Context, referenceID string, tbl *godog.Table) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + directEntitlements, err := parseDirectEntitlementsTable(tbl) + if err != nil { + return ctx, err + } + + claims := map[string]interface{}{ + "direct_entitlements": directEntitlements, + } + structClaims, err := structpb.NewStruct(claims) + if err != nil { + return ctx, err + } + anyClaims, err := anypb.New(structClaims) + if err != nil { + return ctx, err + } + + entity := &entity.Entity{ + EphemeralId: referenceID, + Category: entity.Entity_CATEGORY_SUBJECT, + EntityType: &entity.Entity_Claims{Claims: anyClaims}, + } + scenarioContext.RecordObject(referenceID, entity) + return ctx, nil +} + +func (s *AuthorizationServiceStepDefinitions) iAddClaimsToSubjectEntityWith(ctx context.Context, referenceID string, tbl *godog.Table) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + entityObj, ok := scenarioContext.GetObject(referenceID).(*entity.Entity) + if !ok || entityObj == nil { + return ctx, fmt.Errorf("entity %s not found or invalid type", referenceID) + } + if entityObj.GetClaims() == nil { + return ctx, errors.New("entity does not contain claims") + } + + claimsStruct := &structpb.Struct{} + if err := entityObj.GetClaims().UnmarshalTo(claimsStruct); err != nil { + return ctx, err + } + claimsMap := claimsStruct.AsMap() + + updates, err := parseClaimsTable(tbl) + if err != nil { + return ctx, err + } + for key, value := range updates { + claimsMap[key] = value + } + + updatedStruct, err := structpb.NewStruct(claimsMap) + if err != nil { + return ctx, err + } + anyClaims, err := anypb.New(updatedStruct) + if err != nil { + return ctx, err + } + + entityObj.EntityType = &entity.Entity_Claims{Claims: anyClaims} + scenarioContext.RecordObject(referenceID, entityObj) + return ctx, nil +} + func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChainForActionOnResource(ctx context.Context, entityChainID, action, resource string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) @@ -115,8 +185,110 @@ func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChai return ctx, nil } +func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChainForActionOnResourceWithFulfillableObligations(ctx context.Context, entityChainID, action, resource, fulfillableObligations string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + obligationFQNs := parseFqnsList(fulfillableObligations) + err := s.sendDecisionRequestV2WithFulfillableObligations(ctx, scenarioContext, entityChainID, action, resource, obligationFQNs) + if err != nil { + return ctx, err + } + + return ctx, nil +} + +func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChainForActionOnResourceWithNoFulfillableObligations(ctx context.Context, entityChainID, action, resource string) (context.Context, error) { + return s.iSendADecisionRequestForEntityChainForActionOnResourceWithFulfillableObligations(ctx, entityChainID, action, resource, "[]") +} + +// Step: I send a multi-resource decision request for entity chain "id" for "action" action on resources: (table) +func (s *AuthorizationServiceStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnResources(ctx context.Context, entityChainID string, action string, tbl *godog.Table) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + scenarioContext.ClearError() + + entityChain, err := buildEntityChainFromIDs(scenarioContext, entityChainID) + if err != nil { + return ctx, err + } + + resources, resourceFQNMap, err := buildResourcesFromTable(tbl) + if err != nil { + return ctx, err + } + + // Create v2 multi-resource decision request + req := &authzV2.GetDecisionMultiResourceRequest{ + EntityIdentifier: &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_EntityChain{ + EntityChain: entityChain, + }, + }, + Action: &policy.Action{ + Name: strings.ToLower(action), + }, + Resources: resources, + // For testing purposes, we declare that we can fulfill all obligations + FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), + } + + resp, err := scenarioContext.SDK.AuthorizationV2.GetDecisionMultiResource(ctx, req) + + scenarioContext.SetError(err) + scenarioContext.RecordObject(multiDecisionResponseKey, resp) + scenarioContext.RecordObject(decisionResponse, resp) + scenarioContext.RecordObject("resourceFQNMap", resourceFQNMap) + + return ctx, nil +} + +func (s *AuthorizationServiceStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnResourcesWithNoFulfillableObligations(ctx context.Context, entityChainID string, action string, tbl *godog.Table) (context.Context, error) { + return s.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResourcesWithFulfillableObligations(ctx, entityChainID, action, "[]", tbl) +} + +func (s *AuthorizationServiceStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnResourcesWithFulfillableObligations(ctx context.Context, entityChainID string, action string, fulfillableObligations string, tbl *godog.Table) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + scenarioContext.ClearError() + + entityChain, err := buildEntityChainFromIDs(scenarioContext, entityChainID) + if err != nil { + return ctx, err + } + + resources, resourceFQNMap, err := buildResourcesFromTable(tbl) + if err != nil { + return ctx, err + } + + obligationFQNs := parseFqnsList(fulfillableObligations) + req := &authzV2.GetDecisionMultiResourceRequest{ + EntityIdentifier: &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_EntityChain{ + EntityChain: entityChain, + }, + }, + Action: &policy.Action{ + Name: strings.ToLower(action), + }, + Resources: resources, + FulfillableObligationFqns: obligationFQNs, + } + + resp, err := scenarioContext.SDK.AuthorizationV2.GetDecisionMultiResource(ctx, req) + + scenarioContext.SetError(err) + scenarioContext.RecordObject(multiDecisionResponseKey, resp) + scenarioContext.RecordObject(decisionResponse, resp) + scenarioContext.RecordObject("resourceFQNMap", resourceFQNMap) + + return ctx, nil +} + // Send decision request using v2 API (with obligations support) func (s *AuthorizationServiceStepDefinitions) sendDecisionRequestV2(ctx context.Context, scenarioContext *PlatformScenarioContext, entityChainID string, action string, resource string) error { + return s.sendDecisionRequestV2WithFulfillableObligations(ctx, scenarioContext, entityChainID, action, resource, getAllObligationsFromScenario(scenarioContext)) +} + +func (s *AuthorizationServiceStepDefinitions) sendDecisionRequestV2WithFulfillableObligations(ctx context.Context, scenarioContext *PlatformScenarioContext, entityChainID string, action string, resource string, fulfillableObligations []string) error { // Build entity chain from stored v2 entities var entities []*entity.Entity for _, entityID := range strings.Split(entityChainID, ",") { @@ -155,8 +327,7 @@ func (s *AuthorizationServiceStepDefinitions) sendDecisionRequestV2(ctx context. }, }, }, - // For testing purposes, we declare that we can fulfill all obligations - FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), + FulfillableObligationFqns: fulfillableObligations, } resp, err := scenarioContext.SDK.AuthorizationV2.GetDecision(ctx, req) @@ -185,6 +356,214 @@ func getAllObligationsFromScenario(scenarioContext *PlatformScenarioContext) []s return obligationFQNs } +func buildEntityChainFromIDs(scenarioContext *PlatformScenarioContext, entityChainID string) (*entity.EntityChain, error) { + var entities []*entity.Entity + for _, entityID := range strings.Split(entityChainID, ",") { + ent, ok := scenarioContext.GetObject(strings.TrimSpace(entityID)).(*entity.Entity) + if !ok { + return nil, fmt.Errorf("entity %s not found or invalid type", entityID) + } + entities = append(entities, ent) + } + + return &entity.EntityChain{Entities: entities}, nil +} + +func buildResourcesFromTable(tbl *godog.Table) ([]*authzV2.Resource, map[string]string, error) { + var resources []*authzV2.Resource + resourceFQNMap := make(map[string]string) + resourceIdx := 0 + for ri, row := range tbl.Rows { + if ri == 0 { + continue + } + for _, cell := range row.Cells { + fqn := strings.TrimSpace(cell.Value) + ephemeralID := fmt.Sprintf("resource%d", resourceIdx) + resourceFQNMap[ephemeralID] = fqn + resources = append(resources, &authzV2.Resource{ + EphemeralId: ephemeralID, + Resource: &authzV2.Resource_AttributeValues_{ + AttributeValues: &authzV2.Resource_AttributeValues{ + Fqns: []string{fqn}, + }, + }, + }) + resourceIdx++ + } + } + + if len(resources) == 0 { + return nil, nil, errors.New("no resources provided") + } + + return resources, resourceFQNMap, nil +} + +func parseFqnsList(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "[]" || strings.EqualFold(raw, "none") || strings.EqualFold(raw, "null") { + return nil + } + if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") { + raw = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]")) + if raw == "" { + return nil + } + } + if raw == "" { + return nil + } + out := make([]string, 0) + for f := range strings.SplitSeq(raw, ",") { + f = strings.TrimSpace(f) + if f != "" { + out = append(out, f) + } + } + if len(out) == 0 { + return nil + } + return out +} + +func parseDirectEntitlementsTable(tbl *godog.Table) ([]interface{}, error) { + if tbl == nil || len(tbl.Rows) == 0 { + return nil, errors.New("direct entitlements table is empty") + } + + cellMap := map[string]int{} + for ri, row := range tbl.Rows { + if ri == 0 { + for ci, cell := range row.Cells { + cellMap[cell.Value] = ci + } + break + } + } + + attrIdx, ok := cellMap[directEntitlementColumnAttributeFQN] + if !ok { + return nil, fmt.Errorf("direct entitlements table requires column %s", directEntitlementColumnAttributeFQN) + } + actionsIdx, ok := cellMap[directEntitlementColumnActions] + if !ok { + return nil, fmt.Errorf("direct entitlements table requires column %s", directEntitlementColumnActions) + } + + out := make([]interface{}, 0, len(tbl.Rows)-1) + for ri, row := range tbl.Rows { + if ri == 0 { + continue + } + attrFQN := strings.TrimSpace(row.Cells[attrIdx].Value) + if attrFQN == "" { + return nil, errors.New("direct entitlements require attribute_value_fqn values") + } + + rawActions := "" + if actionsIdx < len(row.Cells) { + rawActions = row.Cells[actionsIdx].Value + } + actions := make([]interface{}, 0) + for _, action := range strings.Split(rawActions, ",") { + action = strings.TrimSpace(action) + if action == "" { + continue + } + actions = append(actions, strings.ToLower(action)) + } + if len(actions) == 0 { + return nil, fmt.Errorf("direct entitlement for %s requires actions", attrFQN) + } + + out = append(out, map[string]interface{}{ + "attribute_value_fqn": attrFQN, + "actions": actions, + }) + } + + if len(out) == 0 { + return nil, errors.New("direct entitlements table has no rows") + } + + return out, nil +} + +func parseClaimsTable(tbl *godog.Table) (map[string]interface{}, error) { + if tbl == nil || len(tbl.Rows) == 0 { + return nil, errors.New("claims table is empty") + } + + cellMap := map[string]int{} + for ri, row := range tbl.Rows { + if ri == 0 { + for ci, cell := range row.Cells { + cellMap[cell.Value] = ci + } + break + } + } + + nameIdx, ok := cellMap["name"] + if !ok { + return nil, errors.New("claims table requires column name") + } + valueIdx, ok := cellMap["value"] + if !ok { + return nil, errors.New("claims table requires column value") + } + + out := map[string]interface{}{} + for ri, row := range tbl.Rows { + if ri == 0 { + continue + } + key := strings.TrimSpace(row.Cells[nameIdx].Value) + if key == "" { + return nil, errors.New("claims table requires name values") + } + rawValue := "" + if valueIdx < len(row.Cells) { + rawValue = strings.TrimSpace(row.Cells[valueIdx].Value) + } + + var parsed interface{} + if rawValue != "" { + if err := json.Unmarshal([]byte(rawValue), &parsed); err != nil { + parsed = rawValue + } + } + out[key] = parsed + } + + if len(out) == 0 { + return nil, errors.New("claims table has no rows") + } + + return out, nil +} + +// Step: I should get N decision responses +func (s *AuthorizationServiceStepDefinitions) iShouldGetNDecisionResponses(ctx context.Context, count int) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + decisionRespV2, ok := scenarioContext.GetObject(multiDecisionResponseKey).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + decisionRespV2, ok = scenarioContext.GetObject(decisionResponse).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + return ctx, errors.New("multi-decision response not found or invalid") + } + } + + actualCount := len(decisionRespV2.GetResourceDecisions()) + if actualCount != count { + return ctx, fmt.Errorf("expected %d decision responses, got %d", count, actualCount) + } + + return ctx, nil +} + func (s *AuthorizationServiceStepDefinitions) iShouldGetADecisionResponse(ctx context.Context, expectedResponse string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) @@ -214,10 +593,79 @@ func (s *AuthorizationServiceStepDefinitions) iShouldGetADecisionResponse(ctx co return ctx, errors.New("decision response not found or invalid") } +// Step: the multi-resource decision should be "PERMIT" or "DENY" +func (s *AuthorizationServiceStepDefinitions) theMultiResourceDecisionShouldBe(ctx context.Context, expectedDecision string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + resp, ok := scenarioContext.GetObject(multiDecisionResponseKey).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + resp, ok = scenarioContext.GetObject(decisionResponse).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + return ctx, errors.New("multi-decision response not found or invalid") + } + } + + allPermitted := resp.GetAllPermitted() + if allPermitted == nil { + return ctx, errors.New("multi-decision missing all_permitted flag") + } + + expected := strings.EqualFold(expectedDecision, "PERMIT") + if allPermitted.GetValue() != expected { + return ctx, fmt.Errorf("unexpected multi-decision result: got %v expected %v", allPermitted.GetValue(), expected) + } + + return ctx, nil +} + +// Step: the decision response for resource FQN should be "PERMIT" or "DENY" +func (s *AuthorizationServiceStepDefinitions) theDecisionResponseForResourceShouldBe(ctx context.Context, resourceFQN string, expectedDecision string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + decisionRespV2, ok := scenarioContext.GetObject(multiDecisionResponseKey).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + decisionRespV2, ok = scenarioContext.GetObject(decisionResponse).(*authzV2.GetDecisionMultiResourceResponse) + if !ok { + return ctx, errors.New("multi-decision response not found or invalid") + } + } + + resourceFQNMap, ok := scenarioContext.GetObject("resourceFQNMap").(map[string]string) + if !ok || len(resourceFQNMap) == 0 { + return ctx, errors.New("resourceFQNMap not found or empty") + } + + expectedDecision = "DECISION_" + strings.ToUpper(strings.TrimSpace(expectedDecision)) + for _, rd := range decisionRespV2.GetResourceDecisions() { + if fqn, exists := resourceFQNMap[rd.GetEphemeralResourceId()]; exists && fqn == resourceFQN { + actualDecision := rd.GetDecision().String() + if actualDecision != expectedDecision { + return ctx, fmt.Errorf("unexpected decision for resource %s: %s instead of %s", resourceFQN, actualDecision, expectedDecision) + } + return ctx, nil + } + } + + known := make([]string, 0, len(resourceFQNMap)) + for _, fqn := range resourceFQNMap { + known = append(known, fqn) + } + return ctx, fmt.Errorf("resource %s not found in decision responses (known: %v)", resourceFQN, known) +} + func RegisterAuthorizationStepDefinitions(ctx *godog.ScenarioContext) { stepDefinitions := AuthorizationServiceStepDefinitions{} ctx.Step(`^there is a "([^"]*)" subject entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsASubjectEntityWithValueAndReferencedAs) + ctx.Step(`^there is a claims subject entity referenced as "([^"]*)" with direct entitlements:$`, stepDefinitions.thereIsAClaimsSubjectEntityReferencedAsWithDirectEntitlements) + ctx.Step(`^I add claims to subject entity "([^"]*)" with:$`, stepDefinitions.iAddClaimsToSubjectEntityWith) ctx.Step(`^there is a "([^"]*)" environment entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsAEnvEntityWithValueAndReferencedAs) ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResource) + ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)" with fulfillable obligations "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResourceWithFulfillableObligations) + ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)" with no fulfillable obligations$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResourceWithNoFulfillableObligations) + ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on resources:$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResources) + ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on resources with no fulfillable obligations:$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResourcesWithNoFulfillableObligations) + ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on resources with fulfillable obligations "([^"]*)":$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResourcesWithFulfillableObligations) ctx.Step(`^I should get a "([^"]*)" decision response$`, stepDefinitions.iShouldGetADecisionResponse) + ctx.Step(`^I should get (\d+) decision responses$`, stepDefinitions.iShouldGetNDecisionResponses) + ctx.Step(`^the multi-resource decision should be "([^"]*)"$`, stepDefinitions.theMultiResourceDecisionShouldBe) + ctx.Step(`^the decision response for resource "([^"]*)" should be "([^"]*)"$`, stepDefinitions.theDecisionResponseForResourceShouldBe) } diff --git a/tests-bdd/cukes/steps_obligations.go b/tests-bdd/cukes/steps_obligations.go index 710d96e226..3d26ae98e5 100644 --- a/tests-bdd/cukes/steps_obligations.go +++ b/tests-bdd/cukes/steps_obligations.go @@ -9,7 +9,6 @@ import ( "github.com/cucumber/godog" authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2" "github.com/opentdf/platform/protocol/go/common" - "github.com/opentdf/platform/protocol/go/entity" "github.com/opentdf/platform/protocol/go/policy" "github.com/opentdf/platform/protocol/go/policy/obligations" ) @@ -270,92 +269,6 @@ func (s *ObligationsStepDefinitions) theDecisionResponseShouldNotContainObligati return ctx, errors.New("decision response not found or invalid") } -// Step: I send a multi-resource decision request for entity chain "id" for "action" action on resources: (table) -func (s *ObligationsStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnResources(ctx context.Context, entityChainID string, action string, tbl *godog.Table) (context.Context, error) { - scenarioContext := GetPlatformScenarioContext(ctx) - scenarioContext.ClearError() - - // Build entity chain from stored v2 entities - var entities []*entity.Entity - for _, entityID := range strings.Split(entityChainID, ",") { - ent, ok := scenarioContext.GetObject(strings.TrimSpace(entityID)).(*entity.Entity) - if !ok { - return ctx, fmt.Errorf("entity %s not found or invalid type", entityID) - } - entities = append(entities, ent) - } - - entityChain := &entity.EntityChain{ - Entities: entities, - } - - // Parse resource FQNs from table - var resources []*authzV2.Resource - resourceFQNMap := make(map[string]string) // map ephemeral ID to FQN - resourceIdx := 0 - for ri, row := range tbl.Rows { - if ri == 0 { - continue // Skip header - } - for _, cell := range row.Cells { - fqn := strings.TrimSpace(cell.Value) - ephemeralID := fmt.Sprintf("resource%d", resourceIdx) - resourceFQNMap[ephemeralID] = fqn - resources = append(resources, &authzV2.Resource{ - EphemeralId: ephemeralID, - Resource: &authzV2.Resource_AttributeValues_{ - AttributeValues: &authzV2.Resource_AttributeValues{ - Fqns: []string{fqn}, - }, - }, - }) - resourceIdx++ - } - } - - // Create v2 multi-resource decision request - req := &authzV2.GetDecisionMultiResourceRequest{ - EntityIdentifier: &authzV2.EntityIdentifier{ - Identifier: &authzV2.EntityIdentifier_EntityChain{ - EntityChain: entityChain, - }, - }, - Action: &policy.Action{ - Name: strings.ToLower(action), - }, - Resources: resources, - // For testing purposes, we declare that we can fulfill all obligations - FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), - } - - resp, err := scenarioContext.SDK.AuthorizationV2.GetDecisionMultiResource(ctx, req) - - scenarioContext.SetError(err) - scenarioContext.RecordObject(multiDecisionResponseKey, resp) - scenarioContext.RecordObject("decisionResponse", resp) // Also store as single response for compatibility - scenarioContext.RecordObject("resourceFQNMap", resourceFQNMap) // Store mapping for validation - - return ctx, nil -} - -// Step: I should get N decision responses -func (s *ObligationsStepDefinitions) iShouldGetNDecisionResponses(ctx context.Context, count int) (context.Context, error) { - scenarioContext := GetPlatformScenarioContext(ctx) - - // Check v2 multi-resource response - decisionRespV2, ok := scenarioContext.GetObject(multiDecisionResponseKey).(*authzV2.GetDecisionMultiResourceResponse) - if !ok { - return ctx, errors.New("multi-decision response not found or invalid") - } - - actualCount := len(decisionRespV2.GetResourceDecisions()) - if actualCount != count { - return ctx, fmt.Errorf("expected %d decision responses, got %d", count, actualCount) - } - - return ctx, nil -} - // Step: the decision response for resource FQN should contain obligation func (s *ObligationsStepDefinitions) theDecisionResponseForResourceShouldContainObligation(ctx context.Context, resourceFQN string, obligationFQN string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) @@ -472,9 +385,6 @@ func RegisterObligationsStepDefinitions(ctx *godog.ScenarioContext, _ *PlatformT ctx.Step(`^the decision response should not contain obligation "([^"]*)"$`, stepDefinitions.theDecisionResponseShouldNotContainObligation) ctx.Step(`^the decision response should contain obligations:$`, stepDefinitions.theDecisionResponseShouldContainObligations) - // Multi-resource decision steps - ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on resources:$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnResources) - ctx.Step(`^I should get (\d+) decision responses$`, stepDefinitions.iShouldGetNDecisionResponses) ctx.Step(`^the decision response for resource "([^"]*)" should contain obligation "([^"]*)"$`, stepDefinitions.theDecisionResponseForResourceShouldContainObligation) ctx.Step(`^the decision response for resource "([^"]*)" should not contain any obligations$`, stepDefinitions.theDecisionResponseForResourceShouldNotContainAnyObligations) } diff --git a/tests-bdd/cukes/steps_registeredresources.go b/tests-bdd/cukes/steps_registeredresources.go index 758c215b42..7bd689f145 100644 --- a/tests-bdd/cukes/steps_registeredresources.go +++ b/tests-bdd/cukes/steps_registeredresources.go @@ -21,6 +21,25 @@ const ( aavPairParts = 2 ) +func resolveRegisteredResourceValueFQN(scenarioContext *PlatformScenarioContext, resourceValueRef string) (string, error) { + resourceValueRef = strings.TrimSpace(resourceValueRef) + if rrValue, ok := scenarioContext.GetObject(resourceValueRef).(*policy.RegisteredResourceValue); ok && rrValue != nil { + if rrValue.GetResource() == nil { + return "", fmt.Errorf("registered resource value %s missing resource", resourceValueRef) + } + namespaceName := "" + if rrValue.GetResource().GetNamespace() != nil { + namespaceName = rrValue.GetResource().GetNamespace().GetName() + } + return (&identifier.FullyQualifiedRegisteredResourceValue{ + Namespace: namespaceName, + Name: rrValue.GetResource().GetName(), + Value: rrValue.GetValue(), + }).FQN(), nil + } + return resourceValueRef, nil +} + func (s *RegisteredResourcesStepDefinitions) iSendARequestToCreateARegisteredResourceWith(ctx context.Context, tbl *godog.Table) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) scenarioContext.ClearError() @@ -169,20 +188,9 @@ func (s *RegisteredResourcesStepDefinitions) iSendADecisionRequestForEntityChain entityChain := &entity.EntityChain{Entities: entities} - resourceValueFQN := strings.TrimSpace(resourceValueRef) - if rrValue, ok := scenarioContext.GetObject(resourceValueFQN).(*policy.RegisteredResourceValue); ok && rrValue != nil { - if rrValue.GetResource() == nil { - return ctx, fmt.Errorf("registered resource value %s missing resource", resourceValueRef) - } - namespaceName := "" - if rrValue.GetResource() != nil && rrValue.GetResource().GetNamespace() != nil { - namespaceName = rrValue.GetResource().GetNamespace().GetName() - } - resourceValueFQN = (&identifier.FullyQualifiedRegisteredResourceValue{ - Namespace: namespaceName, - Name: rrValue.GetResource().GetName(), - Value: rrValue.GetValue(), - }).FQN() + resourceValueFQN, err := resolveRegisteredResourceValueFQN(scenarioContext, resourceValueRef) + if err != nil { + return ctx, err } req := &authzV2.GetDecisionRequest{ @@ -209,6 +217,85 @@ func (s *RegisteredResourcesStepDefinitions) iSendADecisionRequestForEntityChain return ctx, nil } +func (s *RegisteredResourcesStepDefinitions) iSendADecisionRequestForRegisteredResourceValueEntityForActionOnResource(ctx context.Context, entityValueRef string, action string, resource string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + entityFQN, err := resolveRegisteredResourceValueFQN(scenarioContext, entityValueRef) + if err != nil { + return ctx, err + } + + var resourceFQNs []string + for r := range strings.SplitSeq(resource, ",") { + resourceFQNs = append(resourceFQNs, strings.TrimSpace(r)) + } + + req := &authzV2.GetDecisionRequest{ + EntityIdentifier: &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: entityFQN, + }, + }, + Action: &policy.Action{Name: strings.ToLower(action)}, + Resource: &authzV2.Resource{ + EphemeralId: "resource1", + Resource: &authzV2.Resource_AttributeValues_{ + AttributeValues: &authzV2.Resource_AttributeValues{ + Fqns: resourceFQNs, + }, + }, + }, + FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), + } + + resp, err := scenarioContext.SDK.AuthorizationV2.GetDecision(ctx, req) + if err != nil { + scenarioContext.SetError(err) + return ctx, err + } + + scenarioContext.RecordObject(decisionResponse, resp) + return ctx, nil +} + +func (s *RegisteredResourcesStepDefinitions) iSendADecisionRequestForRegisteredResourceValueEntityForActionOnRegisteredResourceValue(ctx context.Context, entityValueRef string, action string, resourceValueRef string) (context.Context, error) { + scenarioContext := GetPlatformScenarioContext(ctx) + + entityFQN, err := resolveRegisteredResourceValueFQN(scenarioContext, entityValueRef) + if err != nil { + return ctx, err + } + resourceFQN, err := resolveRegisteredResourceValueFQN(scenarioContext, resourceValueRef) + if err != nil { + return ctx, err + } + + req := &authzV2.GetDecisionRequest{ + EntityIdentifier: &authzV2.EntityIdentifier{ + Identifier: &authzV2.EntityIdentifier_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: entityFQN, + }, + }, + Action: &policy.Action{Name: strings.ToLower(action)}, + Resource: &authzV2.Resource{ + EphemeralId: "resource1", + Resource: &authzV2.Resource_RegisteredResourceValueFqn{ + RegisteredResourceValueFqn: resourceFQN, + }, + }, + FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext), + } + + resp, err := scenarioContext.SDK.AuthorizationV2.GetDecision(ctx, req) + if err != nil { + scenarioContext.SetError(err) + return ctx, err + } + + scenarioContext.RecordObject(decisionResponse, resp) + return ctx, nil +} + func (s *RegisteredResourcesStepDefinitions) iSendAMultiResourceDecisionRequestForEntityChainForActionOnRegisteredResourceValues(ctx context.Context, entityChainID string, action string, resourceValueRefs string) (context.Context, error) { scenarioContext := GetPlatformScenarioContext(ctx) scenarioContext.ClearError() @@ -227,21 +314,9 @@ func (s *RegisteredResourcesStepDefinitions) iSendAMultiResourceDecisionRequestF resources := make([]*authzV2.Resource, 0) resourceFQNMap := make(map[string]string) for idx, resourceValueRef := range strings.Split(resourceValueRefs, ",") { - resourceValueRef = strings.TrimSpace(resourceValueRef) - resourceValueFQN := resourceValueRef - if rrValue, ok := scenarioContext.GetObject(resourceValueRef).(*policy.RegisteredResourceValue); ok && rrValue != nil { - if rrValue.GetResource() == nil { - return ctx, fmt.Errorf("registered resource value %s missing resource", resourceValueRef) - } - namespaceName := "" - if rrValue.GetResource().GetNamespace() != nil { - namespaceName = rrValue.GetResource().GetNamespace().GetName() - } - resourceValueFQN = (&identifier.FullyQualifiedRegisteredResourceValue{ - Namespace: namespaceName, - Name: rrValue.GetResource().GetName(), - Value: rrValue.GetValue(), - }).FQN() + resourceValueFQN, err := resolveRegisteredResourceValueFQN(scenarioContext, resourceValueRef) + if err != nil { + return ctx, err } ephemeralID := fmt.Sprintf("rrv-%d", idx) @@ -300,6 +375,8 @@ func RegisterRegisteredResourcesStepDefinitions(ctx *godog.ScenarioContext) { ctx.Step(`^I send a request to create a registered resource with:$`, stepDefinitions.iSendARequestToCreateARegisteredResourceWith) ctx.Step(`^I send a request to create a registered resource value with:$`, stepDefinitions.iSendARequestToCreateARegisteredResourceValueWith) ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on registered resource value "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnRegisteredResourceValue) + ctx.Step(`^I send a decision request for registered resource value entity "([^"]*)" for "([^"]*)" action on resource "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForRegisteredResourceValueEntityForActionOnResource) + ctx.Step(`^I send a decision request for registered resource value entity "([^"]*)" for "([^"]*)" action on registered resource value "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForRegisteredResourceValueEntityForActionOnRegisteredResourceValue) ctx.Step(`^I send a multi-resource decision request for entity chain "([^"]*)" for "([^"]*)" action on registered resource values "([^"]*)"$`, stepDefinitions.iSendAMultiResourceDecisionRequestForEntityChainForActionOnRegisteredResourceValues) ctx.Step(`^the multi-resource decision should be "([^"]*)"$`, stepDefinitions.theMultiResourceDecisionShouldBe) } diff --git a/tests-bdd/features/attribute-rules.feature b/tests-bdd/features/attribute-rules.feature new file mode 100644 index 0000000000..92fe304d7b --- /dev/null +++ b/tests-bdd/features/attribute-rules.feature @@ -0,0 +1,111 @@ +@authorization @attribute-rules +Feature: Attribute Rule Decisioning + Validate basic anyOf, allOf, and hierarchy rule behavior in decisioning. + + Background: + Given a user exists with username "alice" and email "alice@example.com" and the following attributes: + | name | value | + | department | ["eng"] | + | project | ["alpha","beta"] | + | sensitivity | ["high"] | + And an empty local platform + And I submit a request to create a namespace with name "example.com" and reference id "ns1" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns1 | department | anyOf | eng,hr | + | ns1 | project | allOf | alpha,beta | + | ns1 | sensitivity | hierarchy | critical,high,medium,low | + Then the response should be successful + And a condition group referenced as "cg_department_eng" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.department[] | in | eng | + And a condition group referenced as "cg_project_alpha" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.project[] | in | alpha | + And a condition group referenced as "cg_project_beta" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.project[] | in | beta | + And a condition group referenced as "cg_sensitivity_high" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.sensitivity[] | in | high | + And a subject set referenced as "ss_department_eng" containing the condition groups "cg_department_eng" + And a subject set referenced as "ss_project_alpha" containing the condition groups "cg_project_alpha" + And a subject set referenced as "ss_project_beta" containing the condition groups "cg_project_beta" + And a subject set referenced as "ss_sensitivity_high" containing the condition groups "cg_sensitivity_high" + And I send a request to create a subject condition set referenced as "scs_department_eng" containing subject sets "ss_department_eng" + And I send a request to create a subject condition set referenced as "scs_project_alpha" containing subject sets "ss_project_alpha" + And I send a request to create a subject condition set referenced as "scs_project_beta" containing subject sets "ss_project_beta" + And I send a request to create a subject condition set referenced as "scs_sensitivity_high" containing subject sets "ss_sensitivity_high" + And there is a "user_name" subject entity with value "alice" and referenced as "alice" + + Scenario: anyOf permits when at least one value is entitled + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng | https://example.com/attr/department/value/eng | scs_department_eng | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng,https://example.com/attr/department/value/hr" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: anyOf denies when no values are entitled + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng | https://example.com/attr/department/value/eng | scs_department_eng | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/hr" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: allOf denies when any value lacks entitlement + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_project_alpha | https://example.com/attr/project/value/alpha | scs_project_alpha | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/project/value/alpha,https://example.com/attr/project/value/beta" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: allOf permits when all values are entitled + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_project_alpha | https://example.com/attr/project/value/alpha | scs_project_alpha | read | | + | sm_project_beta | https://example.com/attr/project/value/beta | scs_project_beta | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/project/value/alpha,https://example.com/attr/project/value/beta" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: hierarchy permits when entitled to a higher value + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_sensitivity_high | https://example.com/attr/sensitivity/value/high | scs_sensitivity_high | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/sensitivity/value/medium" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: hierarchy denies when resource is higher than entitled value + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_sensitivity_high | https://example.com/attr/sensitivity/value/high | scs_sensitivity_high | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/sensitivity/value/critical" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: multiple attributes must all pass across requests + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng | https://example.com/attr/department/value/eng | scs_department_eng | read | | + | sm_project_alpha | https://example.com/attr/project/value/alpha | scs_project_alpha | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng,https://example.com/attr/project/value/beta" + Then the response should be successful + And I should get a "DENY" decision response + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_project_beta | https://example.com/attr/project/value/beta | scs_project_beta | read | | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng,https://example.com/attr/project/value/beta" + Then the response should be successful + And I should get a "PERMIT" decision response diff --git a/tests-bdd/features/direct-entitlements.feature b/tests-bdd/features/direct-entitlements.feature new file mode 100644 index 0000000000..19d87c925f --- /dev/null +++ b/tests-bdd/features/direct-entitlements.feature @@ -0,0 +1,66 @@ +@authorization @direct-entitlements +Feature: Direct Entitlements Decisioning + Validate direct entitlement evaluation when allow_direct_entitlements is enabled. + + Background: + Given a user exists with username "alice" and email "alice@example.com" and the following attributes: + | name | value | + | department | ["eng"] | + And a local platform with platform template "cukes/resources/platform.direct_entitlements.template" and keycloak template "cukes/resources/keycloak_base.template" + And I submit a request to create a namespace with name "example.com" and reference id "ns1" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns1 | department | anyOf | eng,hr | + | ns1 | project | allOf | alpha,beta | + Then the response should be successful + + Scenario: Direct entitlement permits for matching action + And there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/eng | read | + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/eng" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Direct entitlement denies for action mismatch + And there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/eng | read | + When I send a decision request for entity chain "alice" for "update" action on resource "https://example.com/attr/department/value/eng" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: Direct entitlement permits for another value + And there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/hr | read | + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/hr" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Direct entitlement permits for synthetic value + And there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/department/value/finance | read | + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/department/value/finance" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Subject mapping and direct entitlements both apply + And there is a claims subject entity referenced as "alice" with direct entitlements: + | attribute_value_fqn | actions | + | https://example.com/attr/project/value/beta | read | + And I add claims to subject entity "alice" with: + | name | value | + | attributes | {"project":["alpha"]} | + And a condition group referenced as "cg_project_alpha" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.project[] | in | alpha | + And a subject set referenced as "ss_project_alpha" containing the condition groups "cg_project_alpha" + And I send a request to create a subject condition set referenced as "scs_project_alpha" containing subject sets "ss_project_alpha" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_project_alpha | https://example.com/attr/project/value/alpha | scs_project_alpha | read | | + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/project/value/alpha,https://example.com/attr/project/value/beta" + Then the response should be successful + And I should get a "PERMIT" decision response diff --git a/tests-bdd/features/multi-resource.feature b/tests-bdd/features/multi-resource.feature new file mode 100644 index 0000000000..38b5017a02 --- /dev/null +++ b/tests-bdd/features/multi-resource.feature @@ -0,0 +1,69 @@ +@authorization @multi-resource +Feature: Multi-resource Decisioning (Non-Obligations) + Validate per-resource decisions and response counts without obligation effects. + + Background: + Given a user exists with username "alice" and email "alice@example.com" and the following attributes: + | name | value | + | department | ["eng"] | + | region | ["us"] | + And an empty local platform + And I submit a request to create a namespace with name "example.com" and reference id "ns1" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns1 | department | anyOf | eng,hr | + | ns1 | region | anyOf | us,eu | + Then the response should be successful + And a condition group referenced as "cg_department_eng" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.department[] | in | eng | + And a condition group referenced as "cg_region_us" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.region[] | in | us | + And a subject set referenced as "ss_department_eng" containing the condition groups "cg_department_eng" + And a subject set referenced as "ss_region_us" containing the condition groups "cg_region_us" + And I send a request to create a subject condition set referenced as "scs_department_eng" containing subject sets "ss_department_eng" + And I send a request to create a subject condition set referenced as "scs_region_us" containing subject sets "ss_region_us" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng | https://example.com/attr/department/value/eng | scs_department_eng | read | | + | sm_region_us | https://example.com/attr/region/value/us | scs_region_us | read | | + Then the response should be successful + And there is a "user_name" subject entity with value "alice" and referenced as "alice" + + Scenario: All resources permitted + When I send a multi-resource decision request for entity chain "alice" for "read" action on resources: + | resource | + | https://example.com/attr/department/value/eng | + | https://example.com/attr/region/value/us | + Then the response should be successful + And I should get 2 decision responses + And the multi-resource decision should be "PERMIT" + And the decision response for resource "https://example.com/attr/department/value/eng" should be "PERMIT" + And the decision response for resource "https://example.com/attr/region/value/us" should be "PERMIT" + + Scenario: Mixed permit and deny across resources + When I send a multi-resource decision request for entity chain "alice" for "read" action on resources: + | resource | + | https://example.com/attr/department/value/eng | + | https://example.com/attr/region/value/eu | + Then the response should be successful + And I should get 2 decision responses + And the multi-resource decision should be "DENY" + And the decision response for resource "https://example.com/attr/department/value/eng" should be "PERMIT" + And the decision response for resource "https://example.com/attr/region/value/eu" should be "DENY" + + Scenario: Action mismatch denies only the non-entitled resource + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng_update | https://example.com/attr/department/value/eng | scs_department_eng | update | | + Then the response should be successful + When I send a multi-resource decision request for entity chain "alice" for "update" action on resources: + | resource | + | https://example.com/attr/department/value/eng | + | https://example.com/attr/region/value/us | + Then the response should be successful + And I should get 2 decision responses + And the multi-resource decision should be "DENY" + And the decision response for resource "https://example.com/attr/department/value/eng" should be "PERMIT" + And the decision response for resource "https://example.com/attr/region/value/us" should be "DENY" diff --git a/tests-bdd/features/obligations.feature b/tests-bdd/features/obligations.feature index 0e303b9769..3ed829116a 100644 --- a/tests-bdd/features/obligations.feature +++ b/tests-bdd/features/obligations.feature @@ -34,6 +34,7 @@ Feature: Obligations Decisioning E2E Tests | reference_id | attribute_value | condition_set_name | standard actions | custom actions | | sm_classification_topsecret | https://example.com/attr/classification/value/topsecret | scs_clearance_topsecret | read,update | | | sm_classification_secret | https://example.com/attr/classification/value/secret | scs_clearance_secret | read,update | | + And there is a "user_name" subject entity with value "alice" and referenced as "alice" Scenario: Create obligation definition with value and verify in decision response Given I send a request to create an obligation with: @@ -45,7 +46,6 @@ Feature: Obligations Decisioning E2E Tests | obligation_name | obligation_value | action | attribute_value | | drm | watermark | read | https://example.com/attr/classification/value/topsecret | Then the response should be successful - Given there is a "user_name" subject entity with value "alice" and referenced as "alice" When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/classification/value/topsecret" Then the response should be successful And I should get a "PERMIT" decision response @@ -91,7 +91,6 @@ Feature: Obligations Decisioning E2E Tests | obligation_name | obligation_value | action | attribute_value | | drm | prevent_download | read | https://example.com/attr/classification/value/topsecret | Then the response should be successful - Given there is a "user_name" subject entity with value "alice" and referenced as "alice" When I send a multi-resource decision request for entity chain "alice" for "read" action on resources: | resource | | https://example.com/attr/classification/value/topsecret | @@ -112,7 +111,6 @@ Feature: Obligations Decisioning E2E Tests | obligation_name | obligation_value | action | attribute_value | | drm | watermark | read | https://example.com/attr/classification/value/secret | Then the response should be successful - Given there is a "user_name" subject entity with value "alice" and referenced as "alice" And there is a "client_id" environment entity with value "app-client" and referenced as "app" And there is a "user_name" subject entity with value "bob" and referenced as "bob" When I send a decision request for entity chain "alice,app,bob" for "read" action on resource "https://example.com/attr/classification/value/secret" @@ -132,7 +130,6 @@ Feature: Obligations Decisioning E2E Tests | obligation_name | obligation_value | action | attribute_value | | drm | prevent_download | read | https://example.com/attr/classification/value/topsecret | Then the response should be successful - Given there is a "user_name" subject entity with value "alice" and referenced as "alice" When I send a multi-resource decision request for entity chain "alice" for "read" action on resources: | resource | | https://example.com/attr/classification/value/topsecret | @@ -160,7 +157,6 @@ Feature: Obligations Decisioning E2E Tests | obligation_name | obligation_value | action | attribute_value | | drm | prevent_download | read | https://example.com/attr/classification/value/topsecret | Then the response should be successful - Given there is a "user_name" subject entity with value "alice" and referenced as "alice" When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/classification/value/topsecret" Then the response should be successful And I should get a "PERMIT" decision response @@ -168,3 +164,93 @@ Feature: Obligations Decisioning E2E Tests | obligation | | https://example.com/obl/drm/value/watermark | | https://example.com/obl/drm/value/prevent_download | + + Scenario: Unfulfilled obligations deny access and return required obligations + Given I send a request to create an obligation with: + | namespace_id | name | values | + | ns1 | drm | watermark | + Then the response should be successful + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | watermark | read | https://example.com/attr/classification/value/topsecret | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/classification/value/topsecret" with fulfillable obligations "[]" + Then the response should be successful + And I should get a "DENY" decision response + And the decision response should contain obligation "https://example.com/obl/drm/value/watermark" + + Scenario: Unentitled access denies without returning obligations + Given I send a request to create an obligation with: + | namespace_id | name | values | + | ns1 | drm | watermark | + Then the response should be successful + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | watermark | read | https://example.com/attr/classification/value/topsecret | + Then the response should be successful + And there is a "user_name" subject entity with value "bob" and referenced as "bob" + When I send a decision request for entity chain "bob" for "read" action on resource "https://example.com/attr/classification/value/topsecret" with fulfillable obligations "[]" + Then the response should be successful + And I should get a "DENY" decision response + And the decision response should not contain obligation "https://example.com/obl/drm/value/watermark" + + Scenario: Mixed obligations across multiple resources + Given I send a request to create an obligation with: + | namespace_id | name | values | + | ns1 | drm | watermark | + Then the response should be successful + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | watermark | read | https://example.com/attr/classification/value/topsecret | + Then the response should be successful + When I send a multi-resource decision request for entity chain "alice" for "read" action on resources with fulfillable obligations "[]": + | resource | + | https://example.com/attr/classification/value/topsecret | + | https://example.com/attr/classification/value/secret | + Then the response should be successful + And I should get 2 decision responses + And the multi-resource decision should be "DENY" + And the decision response for resource "https://example.com/attr/classification/value/topsecret" should be "DENY" + And the decision response for resource "https://example.com/attr/classification/value/secret" should be "PERMIT" + And the decision response for resource "https://example.com/attr/classification/value/topsecret" should contain obligation "https://example.com/obl/drm/value/watermark" + And the decision response for resource "https://example.com/attr/classification/value/secret" should not contain any obligations + + Scenario: Partial fulfillable obligations deny but return all required obligations + Given I send a request to create an obligation with: + | namespace_id | name | values | + | ns1 | drm | watermark,prevent_download | + Then the response should be successful + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | watermark | read | https://example.com/attr/classification/value/topsecret | + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | prevent_download | read | https://example.com/attr/classification/value/topsecret | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/classification/value/topsecret" with fulfillable obligations "https://example.com/obl/drm/value/watermark" + Then the response should be successful + And I should get a "DENY" decision response + And the decision response should contain obligations: + | obligation | + | https://example.com/obl/drm/value/watermark | + | https://example.com/obl/drm/value/prevent_download | + + Scenario: All fulfillable obligations permit and return required obligations + Given I send a request to create an obligation with: + | namespace_id | name | values | + | ns1 | drm | watermark,prevent_download | + Then the response should be successful + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | watermark | read | https://example.com/attr/classification/value/topsecret | + And I send a request to create an obligation trigger with: + | obligation_name | obligation_value | action | attribute_value | + | drm | prevent_download | read | https://example.com/attr/classification/value/topsecret | + Then the response should be successful + When I send a decision request for entity chain "alice" for "read" action on resource "https://example.com/attr/classification/value/topsecret" with fulfillable obligations "https://example.com/obl/drm/value/watermark,https://example.com/obl/drm/value/prevent_download" + Then the response should be successful + And I should get a "PERMIT" decision response + And the decision response should contain obligations: + | obligation | + | https://example.com/obl/drm/value/watermark | + | https://example.com/obl/drm/value/prevent_download | diff --git a/tests-bdd/features/registered-resources.feature b/tests-bdd/features/registered-resources.feature new file mode 100644 index 0000000000..23871ceebc --- /dev/null +++ b/tests-bdd/features/registered-resources.feature @@ -0,0 +1,82 @@ +@authorization @registered-resources +Feature: Registered Resource Decisioning + Validate registered resource value decisioning without strict namespaced policy. + + Background: + Given a user exists with username "alice" and email "alice@example.com" and the following attributes: + | name | value | + | department | ["eng"] | + And a user exists with username "bob" and email "bob@example.com" and the following attributes: + | name | value | + | department | ["hr"] | + And an empty local platform + And I submit a request to create a namespace with name "example.com" and reference id "ns1" + And I send a request to create an attribute with: + | namespace_id | name | rule | values | + | ns1 | department | anyOf | eng,hr | + Then the response should be successful + And a condition group referenced as "cg_department_eng" with an "or" operator with conditions: + | selector_value | operator | values | + | .attributes.department[] | in | eng | + And a subject set referenced as "ss_department_eng" containing the condition groups "cg_department_eng" + And I send a request to create a subject condition set referenced as "scs_department_eng" containing subject sets "ss_department_eng" + And I send a request to create a subject mapping with: + | reference_id | attribute_value | condition_set_name | standard actions | custom actions | + | sm_department_eng | https://example.com/attr/department/value/eng | scs_department_eng | read | | + Then the response should be successful + And I send a request to create a registered resource with: + | reference_id | namespace_id | name | + | rr_entity | ns1 | service-a | + | rr_target | ns1 | service-b | + Then the response should be successful + And I send a request to create a registered resource with: + | reference_id | name | + | rr_legacy | legacy-service | + Then the response should be successful + And I send a request to create a registered resource value with: + | reference_id | resource_ref | value | action_attribute_values | + | rrv_entity | rr_entity | primary-eng | read=>https://example.com/attr/department/value/eng | + | rrv_target_prod | rr_target | prod-eng | read=>https://example.com/attr/department/value/eng | + | rrv_target_staging | rr_target | staging-hr | read=>https://example.com/attr/department/value/hr | + | rrv_legacy | rr_legacy | legacy-eng | read=>https://example.com/attr/department/value/eng | + Then the response should be successful + And there is a "user_name" subject entity with value "alice" and referenced as "alice" + + Scenario: Registered resource value as resource permits for entitled user + When I send a decision request for entity chain "alice" for "read" action on registered resource value "rrv_target_prod" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Registered resource value as resource denies for non-entitled user + And there is a "user_name" subject entity with value "bob" and referenced as "bob" + When I send a decision request for entity chain "bob" for "read" action on registered resource value "rrv_target_prod" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: Registered resource value as resource permits using legacy FQN + When I send a decision request for entity chain "alice" for "read" action on registered resource value "https://reg_res/legacy-service/value/legacy-eng" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Registered resource value as resource denies when AAV is not entitled + When I send a decision request for entity chain "alice" for "read" action on registered resource value "rrv_target_staging" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: Registered resource value as entity permits and denies across requests + When I send a decision request for registered resource value entity "rrv_entity" for "read" action on resource "https://example.com/attr/department/value/eng" + Then the response should be successful + And I should get a "PERMIT" decision response + When I send a decision request for registered resource value entity "rrv_entity" for "read" action on resource "https://example.com/attr/department/value/hr" + Then the response should be successful + And I should get a "DENY" decision response + + Scenario: Registered resource value as entity and resource permits + When I send a decision request for registered resource value entity "rrv_entity" for "read" action on registered resource value "rrv_target_prod" + Then the response should be successful + And I should get a "PERMIT" decision response + + Scenario: Registered resource value as entity and resource denies when resource AAV is not entitled + When I send a decision request for registered resource value entity "rrv_entity" for "read" action on registered resource value "rrv_target_staging" + Then the response should be successful + And I should get a "DENY" decision response