Skip to content

Commit 5431e7e

Browse files
alkalescentCopilotgemini-code-assist[bot]
authored
chore(authz): add obligations decisioning BDD tests (#2893)
### Proposed Changes * ### Checklist - [ ] I have added or updated unit tests - [ ] I have added or updated integration tests (if appropriate) - [ ] I have added or updated documentation ### Testing Instructions --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 99c056e commit 5431e7e

File tree

11 files changed

+1126
-12
lines changed

11 files changed

+1126
-12
lines changed

tests-bdd/README.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ Project layout:
3737
- `features`: folder containing Gherkin feature files
3838
- `tests-bdd`: contains cucumber test suites.
3939

40+
### Authorization Testing Coverage
41+
42+
The BDD tests cover the following entity and resource types:
43+
44+
**Entity Types:**
45+
-**Entity Chains** - One or more entities (PE/NPE) combined for authorization decisioning
46+
- Supported entity types: `user_name`, `email_address`, `client_id`, `claims`
47+
- Examples: single user entity, user + client_id chain, multiple chained entities
48+
-**Registered Resource Values as Entities** - Not currently tested
49+
-**Token (JWT)** - Entity derived from access token - Not currently tested
50+
-**Request Token** - Entity derived from request's authorization header - Not currently tested
51+
52+
**Resource Types:**
53+
-**Attribute Values** - Sets of attribute value FQNs (e.g., on a TDF)
54+
- Single and multi-resource decision requests
55+
-**Registered Resource Values** - Not currently tested
56+
4057
### Platform Test Suite
4158
This test suite exercises platform level cukes tests. The [Platform Test](./platform_test.go) initializes the test suite
4259
and registers Step Definitions.
@@ -112,6 +129,22 @@ component or feature testing should use new test suites and new step definitions
112129

113130
Leverage tooling to help generate and author tests.
114131

132+
### Expanding Test Coverage
133+
134+
To expand authorization testing to cover additional entity and resource types:
135+
136+
1. **Add Step Definitions** for creating entities/resources of the new type:
137+
- Example: `thereIsATokenEntityWithValueAndReferencedAs()` for JWT token entities
138+
- Example: `thereIsARegisteredResourceValueReferencedAs()` for registered resources
139+
140+
2. **Update Entity/Resource Creation** in [steps_authorization.go](cukes/steps_authorization.go):
141+
- Extend `createEntity()` or add new helper functions to support new entity identifier types
142+
- Add support for registered resource value FQNs in decision request steps
143+
144+
3. **Add Test Scenarios** covering the new types in feature files
145+
146+
See [authorization v2 API](../service/authorization/v2/authorization.proto) for the complete entity and resource type definitions.
147+
115148
### Scenario Generation
116149
AI can be useful to generate scenarios from service descriptions.
117150

@@ -141,15 +174,17 @@ TBD
141174
You can use the `--godog.tags` option to run subsets of scenarios based on their tags. For example:
142175

143176
```shell
144-
go test ./tests-bdd/platform_test.go -v --tags=cukes --godog.tags="@fast or @unit"
177+
go test ./tests-bdd/platform_test.go -v --tags=cukes --godog.tags="@fast,@unit"
145178
```
146179

147-
This will run only scenarios that have either the `@fast` or `@unit` tag. You can use complex expressions with `and`, `or`, and `not` operators:
180+
This will run scenarios that have either the `@fast` or `@unit` tag (the comma acts as an OR operator). You can use complex expressions with `and`, `or`, and `not` operators:
148181

149182
```shell
150183
go test ./tests-bdd/platform_test.go -v --tags=cukes --godog.tags="@fast and not @slow"
151184
```
152185

186+
Note: Godog uses commas (`,`) for OR logic, the word `and` for AND logic, and `not` for negation.
187+
153188
The tags option works with any of the test files, allowing you to run specific subsets of scenarios across different test groups.
154189

155190
### Pre-req for Colima Docker Engine
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
package cukes
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
9+
"github.com/cucumber/godog"
10+
authzV2 "github.com/opentdf/platform/protocol/go/authorization/v2"
11+
"github.com/opentdf/platform/protocol/go/entity"
12+
"github.com/opentdf/platform/protocol/go/policy"
13+
"google.golang.org/protobuf/encoding/protojson"
14+
"google.golang.org/protobuf/types/known/anypb"
15+
)
16+
17+
type AuthorizationServiceStepDefinitions struct{}
18+
19+
const (
20+
decisionResponse = "decisionResponse"
21+
)
22+
23+
func ConvertInterfaceToAny(jsonData []byte) (*anypb.Any, error) {
24+
// Create an empty Any
25+
anyMsg := &anypb.Any{}
26+
27+
// Use protojson's Unmarshal which handles @type automatically
28+
if err := protojson.Unmarshal(jsonData, anyMsg); err != nil {
29+
return nil, err
30+
}
31+
return anyMsg, nil
32+
}
33+
34+
func GetActionsFromValues(standardActions *string, customActions *string) []*policy.Action {
35+
var actions []*policy.Action
36+
if standardActions != nil {
37+
for value := range strings.SplitSeq(*standardActions, ",") {
38+
trimValue := strings.TrimSpace(value)
39+
if trimValue != "" {
40+
action := &policy.Action{
41+
Name: strings.ToLower(trimValue),
42+
}
43+
actions = append(actions, action)
44+
}
45+
}
46+
}
47+
if customActions != nil {
48+
for value := range strings.SplitSeq(*customActions, ",") {
49+
trimValue := strings.TrimSpace(value)
50+
if trimValue != "" {
51+
v := "CUSTOM_ACTION_" + trimValue
52+
actions = append(actions, &policy.Action{
53+
Name: strings.ToLower(v),
54+
})
55+
}
56+
}
57+
}
58+
return actions
59+
}
60+
61+
func (s *AuthorizationServiceStepDefinitions) createEntity(referenceID string, entityCategory string, entityIDType string, entityIDValue string) (*entity.Entity, error) {
62+
ent := &entity.Entity{
63+
EphemeralId: referenceID,
64+
Category: entity.Entity_Category(entity.Entity_Category_value["CATEGORY_"+entityCategory]),
65+
}
66+
// v2 entity types: email_address|user_name|claims|client_id
67+
switch entityIDType {
68+
case "email_address":
69+
ent.EntityType = &entity.Entity_EmailAddress{EmailAddress: entityIDValue}
70+
case "user_name":
71+
ent.EntityType = &entity.Entity_UserName{UserName: entityIDValue}
72+
case "claims":
73+
claims, err := ConvertInterfaceToAny([]byte(entityIDValue))
74+
if err != nil {
75+
return ent, err
76+
}
77+
ent.EntityType = &entity.Entity_Claims{Claims: claims}
78+
case "client_id":
79+
ent.EntityType = &entity.Entity_ClientId{ClientId: entityIDValue}
80+
default:
81+
return ent, fmt.Errorf("unsupported entity type: %s (v2 only supports: email_address, user_name, claims, client_id)", entityIDType)
82+
}
83+
return ent, nil
84+
}
85+
86+
func (s *AuthorizationServiceStepDefinitions) thereIsAEnvEntityWithValueAndReferencedAs(ctx context.Context, entityIDType string, entityIDValue string, referenceID string) (context.Context, error) {
87+
scenarioContext := GetPlatformScenarioContext(ctx)
88+
entity, err := s.createEntity(referenceID, "ENVIRONMENT", entityIDType, entityIDValue)
89+
if err != nil {
90+
return ctx, err
91+
}
92+
scenarioContext.RecordObject(referenceID, entity)
93+
return ctx, nil
94+
}
95+
96+
func (s *AuthorizationServiceStepDefinitions) thereIsASubjectEntityWithValueAndReferencedAs(ctx context.Context, entityIDType string, entityIDValue string, referenceID string) (context.Context, error) {
97+
scenarioContext := GetPlatformScenarioContext(ctx)
98+
entity, err := s.createEntity(referenceID, "SUBJECT", entityIDType, entityIDValue)
99+
if err != nil {
100+
return ctx, err
101+
}
102+
scenarioContext.RecordObject(referenceID, entity)
103+
return ctx, nil
104+
}
105+
106+
func (s *AuthorizationServiceStepDefinitions) iSendADecisionRequestForEntityChainForActionOnResource(ctx context.Context, entityChainID, action, resource string) (context.Context, error) {
107+
scenarioContext := GetPlatformScenarioContext(ctx)
108+
109+
// Send decision request using v2 API (with obligations support)
110+
err := s.sendDecisionRequestV2(ctx, scenarioContext, entityChainID, action, resource)
111+
if err != nil {
112+
return ctx, err
113+
}
114+
115+
return ctx, nil
116+
}
117+
118+
// Send decision request using v2 API (with obligations support)
119+
func (s *AuthorizationServiceStepDefinitions) sendDecisionRequestV2(ctx context.Context, scenarioContext *PlatformScenarioContext, entityChainID string, action string, resource string) error {
120+
// Build entity chain from stored v2 entities
121+
var entities []*entity.Entity
122+
for _, entityID := range strings.Split(entityChainID, ",") {
123+
ent, ok := scenarioContext.GetObject(strings.TrimSpace(entityID)).(*entity.Entity)
124+
if !ok {
125+
return errors.New("object not of expected type Entity")
126+
}
127+
entities = append(entities, ent)
128+
}
129+
130+
entityChain := &entity.EntityChain{
131+
Entities: entities,
132+
}
133+
134+
// Parse resource FQNs
135+
var resourceFQNs []string
136+
for r := range strings.SplitSeq(resource, ",") {
137+
resourceFQNs = append(resourceFQNs, strings.TrimSpace(r))
138+
}
139+
140+
// Create v2 decision request
141+
req := &authzV2.GetDecisionRequest{
142+
EntityIdentifier: &authzV2.EntityIdentifier{
143+
Identifier: &authzV2.EntityIdentifier_EntityChain{
144+
EntityChain: entityChain,
145+
},
146+
},
147+
Action: &policy.Action{
148+
Name: strings.ToLower(action),
149+
},
150+
Resource: &authzV2.Resource{
151+
EphemeralId: "resource1",
152+
Resource: &authzV2.Resource_AttributeValues_{
153+
AttributeValues: &authzV2.Resource_AttributeValues{
154+
Fqns: resourceFQNs,
155+
},
156+
},
157+
},
158+
// For testing purposes, we declare that we can fulfill all obligations
159+
FulfillableObligationFqns: getAllObligationsFromScenario(scenarioContext),
160+
}
161+
162+
resp, err := scenarioContext.SDK.AuthorizationV2.GetDecision(ctx, req)
163+
if err != nil {
164+
return err
165+
}
166+
167+
scenarioContext.RecordObject(decisionResponse, resp)
168+
return nil
169+
}
170+
171+
// Helper to get all obligation value FQNs from the scenario context
172+
func getAllObligationsFromScenario(scenarioContext *PlatformScenarioContext) []string {
173+
var obligationFQNs []string
174+
175+
// Get all obligations stored in the scenario context
176+
for _, obj := range scenarioContext.objects {
177+
if obligation, ok := obj.(*policy.Obligation); ok {
178+
// For each obligation, add all its value FQNs
179+
for _, ov := range obligation.GetValues() {
180+
obligationFQNs = append(obligationFQNs, ov.GetFqn())
181+
}
182+
}
183+
}
184+
185+
return obligationFQNs
186+
}
187+
188+
func (s *AuthorizationServiceStepDefinitions) iShouldGetADecisionResponse(ctx context.Context, expectedResponse string) (context.Context, error) {
189+
scenarioContext := GetPlatformScenarioContext(ctx)
190+
191+
// Try v2 single-resource response first
192+
if getDecisionsResponseV2, ok := scenarioContext.GetObject(decisionResponse).(*authzV2.GetDecisionResponse); ok {
193+
expectedResponse = "DECISION_" + expectedResponse
194+
actualDecision := getDecisionsResponseV2.GetDecision().GetDecision().String()
195+
if expectedResponse != actualDecision {
196+
return ctx, fmt.Errorf("unexpected response: %s instead of %s", actualDecision, expectedResponse)
197+
}
198+
return ctx, nil
199+
}
200+
201+
// Try v2 multi-resource response (check first resource decision)
202+
if getDecisionsResponseV2Multi, ok := scenarioContext.GetObject(decisionResponse).(*authzV2.GetDecisionMultiResourceResponse); ok {
203+
if len(getDecisionsResponseV2Multi.GetResourceDecisions()) == 0 {
204+
return ctx, errors.New("no resource decisions found in multi-resource response")
205+
}
206+
expectedResponse = "DECISION_" + expectedResponse
207+
actualDecision := getDecisionsResponseV2Multi.GetResourceDecisions()[0].GetDecision().String()
208+
if expectedResponse != actualDecision {
209+
return ctx, fmt.Errorf("unexpected response: %s instead of %s", actualDecision, expectedResponse)
210+
}
211+
return ctx, nil
212+
}
213+
214+
return ctx, errors.New("decision response not found or invalid")
215+
}
216+
217+
func RegisterAuthorizationStepDefinitions(ctx *godog.ScenarioContext) {
218+
stepDefinitions := AuthorizationServiceStepDefinitions{}
219+
ctx.Step(`^there is a "([^"]*)" subject entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsASubjectEntityWithValueAndReferencedAs)
220+
ctx.Step(`^there is a "([^"]*)" environment entity with value "([^"]*)" and referenced as "([^"]*)"$`, stepDefinitions.thereIsAEnvEntityWithValueAndReferencedAs)
221+
ctx.Step(`^I send a decision request for entity chain "([^"]*)" for "([^"]*)" action on resource "([^"]*)"$`, stepDefinitions.iSendADecisionRequestForEntityChainForActionOnResource)
222+
ctx.Step(`^I should get a "([^"]*)" decision response$`, stepDefinitions.iShouldGetADecisionResponse)
223+
}

tests-bdd/cukes/steps_localplatform.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ func RegisterLocalPlatformStepDefinitions(ctx *godog.ScenarioContext, x *Platfor
410410
platformStepDefinitions := LocalPlatformStepDefinitions{
411411
PlatformCukesContext: x,
412412
}
413-
ctx.Step(`^a empty local platform$`, platformStepDefinitions.aEmptyLocalPlatform)
413+
ctx.Step(`^an empty local platform$`, platformStepDefinitions.aEmptyLocalPlatform)
414414
ctx.Step(`^a user exists with username "([^"]*)" and email "([^"]*)" and the following attributes:$`, platformStepDefinitions.aUser)
415415
ctx.Step(`^a local platform with platform template "([^"]*)" and keycloak template "([^"]*)"$`, platformStepDefinitions.aLocalPlatformWithTemplates)
416416
}

0 commit comments

Comments
 (0)