diff --git a/api/consent-management-API.yaml b/api/consent-management-API.yaml index 8e3915b6..6333f43d 100644 --- a/api/consent-management-API.yaml +++ b/api/consent-management-API.yaml @@ -119,9 +119,10 @@ paths: get: summary: Search for consents description: | - Provides a powerful search capability to find consents based on various filter criteria like consent type, status, group ID, user ID, purpose, and element. Supports pagination. + Provides a powerful search capability to find consents based on various filter criteria like consent type, status, group ID, user ID, purpose, element, and delegation role. Supports pagination. If purposeVersion is specified, purposeName must also be provided. If elementVersion is specified, at least one of elementName or elementNamespace must also be provided. + The `delegation` and `authTypes` parameters are mutually exclusive. Use `delegation` for the built-in self/delegation role shortcut, or `authTypes` to match explicit authorization type values. operationId: consents-search-GET tags: @@ -189,6 +190,48 @@ paths: schema: type: string example: "v2" + - name: delegation + in: query + description: | + Filter consents by delegation role. Used to separate self-consents from delegated consents. + - `true`: Returns consents where the user (specified by `userIds`) has authorization type `delegate`. + - `false`: Returns consents where the user has authorization type `primary` or `default` (self-consents). + - When omitted: Returns all consents for the user regardless of authorization type. + + Can be used without `userIds` to query all delegation or self-consents system-wide. + Cannot be used together with `authTypes` (mutually exclusive). + required: false + schema: + type: boolean + example: true + - name: delegateSubject + in: query + description: | + Filter consents where the specified user ID is the delegate subject (i.e., a person whose data is being + consented for by someone else). Returns consents where this user has authorization type `delegate_subject`. + + Can be combined with `delegation=true` and `userIds` to find consents where a specific delegate manages + a specific subject. For example: `?delegation=true&userIds=parent@example.com&delegateSubject=child@example.com` + required: false + schema: + type: string + example: "child@example.com" + - name: authTypes + in: query + description: | + A comma-separated list of authorization type values to filter by. Accepts any authorization type + string, including first-class types (`primary`, `delegate`, `delegate_subject`) and custom types + (e.g., `agent`, `carer`, `owner`). + + When combined with `userIds`, returns consents where the specified user has the given authorization type. + When used alone, returns all consents that contain the specified authorization type. + + Cannot be used together with `delegation` (mutually exclusive). Use `delegation` for the + built-in self/delegation role shortcut, or `authTypes` for explicit type matching. + required: false + schema: + type: string + example: "agent" - name: fromTime in: query description: The start of the time window for the search, as a Unix timestamp in milliseconds (integer). @@ -2744,13 +2787,37 @@ components: **Authorization Type:** The `type` field is optional. When not provided, the server defaults to `"default"`. + `"default"` is treated as legacy self-consent, equivalent to `primary` for validation + and `delegation=false` searches. + + OpenFGC recognizes three **first-class authorization types** that are validated: + - `primary`: Self-consent — the person consenting for themselves. + - `default`: Legacy self-consent used when `type` is omitted; treated like `primary`. + - `delegate`: A person giving consent on behalf of another (e.g., a parent). + - `delegate_subject`: A person who cannot consent for themselves (e.g., a minor child). + + Data holders can also use **custom types** (e.g., `agent`, `carer`, `owner`). Custom types + are stored and filterable but not validated by OpenFGC. + + **First-Class Type Validation Rules:** + - `delegate` requires at least one `delegate_subject` in the same consent. + - `delegate_subject` requires at least one `delegate` in the same consent. + - `primary`/`default` cannot be mixed with `delegate` or `delegate_subject` in the same consent. + - Custom types skip these validation rules entirely. **Authorization Status/State:** The `status` field represents the authorization state which determines the consent status: - - `approved` (default if not provided): Authorization is approved → consent will be "active" - - `created`: Authorization is created but not yet approved → consent will be "created" - - `rejected`: Authorization was rejected → consent will be "rejected" + - `APPROVED` (default if not provided): Authorization is approved → consent will be ACTIVE + - `CREATED`: Authorization is created but not yet approved → consent will be CREATED + - `REJECTED`: Authorization was rejected → consent will be REJECTED + - `RECORDED`: Passive participant — no action needed from this person (e.g., a child in + parent-child delegation, or an AI agent). RECORDED statuses are **skipped** during consent + status derivation. - Custom values: Will be resolved via extension point to one of the known consent statuses + + **Universal Participation Rule:** + At least one authorization in a consent must have a non-RECORDED status. This prevents + consents where nobody actively consented. required: - userId properties: @@ -2759,9 +2826,19 @@ components: type: string example: "admin@carbon.super" type: - description: The type of authorization (e.g., 'authorisation', 're-authorisation'). Defaults to "default" when not provided. + description: | + The type of authorization. Defaults to `"default"` when not provided. + + **First-class types** (validated by OpenFGC): + - `primary`: Self-consent — the person consenting for themselves + - `default`: Legacy self-consent used when `type` is omitted; treated like `primary` + - `delegate`: Person giving consent on behalf of another (e.g., a parent) + - `delegate_subject`: Person who cannot consent for themselves (e.g., a minor child) + + **Custom types** (stored and filterable, not validated): + Any other string value (e.g., `agent`, `carer`, `owner`, `authorisation`) type: string - example: "authorisation" + example: "primary" status: description: | The current status/state of this specific authorization. @@ -2770,13 +2847,10 @@ components: - `APPROVED`: Authorization approved (default) → consent becomes ACTIVE - `CREATED`: Authorization created but not yet approved → consent becomes CREATED - `REJECTED`: Authorization rejected → consent becomes REJECTED + - `RECORDED`: Passive participant, no action needed → **skipped** in consent status derivation - Custom states: Resolved via extension point type: string example: "APPROVED" - enum: - - CREATED - - APPROVED - - REJECTED resources: description: | Flexible resources field that can contain any valid JSON structure. @@ -2817,10 +2891,33 @@ components: **Status Derivation:** The consent status is NOT provided in the request. Instead, it is automatically derived by the API based on the authorization states: - - If any authorization has status REJECTED → consent status = REJECTED - - If any authorization has status CREATED → consent status = CREATED - - If all authorizations have status APPROVED (or empty/default) → consent status = ACTIVE - - For custom authorization states → consent status is resolved via extension point (must return a known status) + 1. First, non-participating statuses are filtered out: `RECORDED`, `SYS_EXPIRED`, and `SYS_REVOKED` are excluded from derivation. + 2. From the remaining participating statuses: + - If any authorization has status REJECTED → consent status = REJECTED + - If any authorization has status CREATED → consent status = CREATED + - If all authorizations have status APPROVED (or empty/default) → consent status = ACTIVE + - For custom authorization states → consent status is resolved via extension point (must return a known status) + + **Consent Delegation:** + Consent delegation is when one person creates and manages a consent on behalf of another person who cannot consent for themselves. + Use the first-class authorization types (`primary`, `delegate`, `delegate_subject`) to model delegation relationships. + + Example — parent consenting for a child: + ```json + "authorizations": [ + { "userId": "parent@example.com", "type": "delegate", "status": "APPROVED" }, + { "userId": "child@example.com", "type": "delegate_subject", "status": "RECORDED" } + ] + ``` + Derivation: RECORDED is skipped → only APPROVED remains → consent becomes ACTIVE. + + **Authorization Type Validation Rules:** + When first-class types are used, OpenFGC enforces: + - `delegate` requires at least one `delegate_subject` in the same consent + - `delegate_subject` requires at least one `delegate` in the same consent + - `primary`/`default` cannot be mixed with `delegate` or `delegate_subject` + - At least one authorization must have a non-RECORDED status + - Custom types skip the pairing rules but still require at least one non-RECORDED status **Known Consent Statuses:** - `CREATED`: Consent is created but not yet approved @@ -2884,13 +2981,26 @@ components: ConsentAuthorizationCreatePayload: type: object description: | - Represents a detailed authorization object. + Represents a detailed authorization object within a consent create/update request. + + **Authorization Type:** + The `type` field categorizes the authorization. OpenFGC recognizes three first-class types: + - `primary`: Self-consent — the person consenting for themselves + - `default`: Legacy self-consent used when `type` is omitted; treated like `primary` + - `delegate`: Person giving consent on behalf of another (e.g., a parent) + - `delegate_subject`: Person who cannot consent for themselves (e.g., a minor child) + + `primary`/`default` cannot be mixed with `delegate` or `delegate_subject` in the same consent. + + Custom types (e.g., `agent`, `carer`, `owner`) are also supported — stored and filterable + but not validated by OpenFGC. **Authorization State:** The status field represents the authorization state which determines the consent status: - `APPROVED` (default if not provided): Authorization is approved → consent will be ACTIVE - `CREATED`: Authorization is created but not yet approved → consent will be CREATED - `REJECTED`: Authorization was rejected → consent will be REJECTED + - `RECORDED`: Passive participant, skipped in consent status derivation - Custom states: Will be resolved via extension point to determine final consent status required: - userId @@ -2900,20 +3010,20 @@ components: type: string example: "admin@carbon.super" type: - description: The type of authorization (e.g., 'authorisation', 're-authorisation'). + description: | + The type of authorization. First-class types: `primary`, `default`, `delegate`, `delegate_subject`. + Custom types (e.g., `agent`, `carer`) are also accepted. + `default` is used when `type` is omitted and is treated like `primary`. type: string - example: "authorisation" + example: "primary" status: description: | The authorization state (defaults to APPROVED if not provided). - Known states: APPROVED, CREATED, REJECTED. + Known states: APPROVED, CREATED, REJECTED, RECORDED. + RECORDED is skipped during consent status derivation. Custom states will be resolved via extension point. type: string example: "APPROVED" - enum: - - CREATED - - APPROVED - - REJECTED resources: description: | Flexible resources field that can contain any valid JSON structure. @@ -3000,10 +3110,12 @@ components: **Status Derivation:** The consent status is NOT provided in the request. Instead, it is automatically derived by the API based on the authorization states: - - If any authorization has status "rejected" → consent status = "rejected" - - If any authorization has status "created" → consent status = "created" - - If all authorizations have status "approved" (or empty/default) → consent status = "active" - - For custom authorization states → consent status is resolved via extension point (must return a known status) + 1. First, non-participating statuses are filtered out: `RECORDED`, `SYS_EXPIRED`, and `SYS_REVOKED` are excluded from derivation. + 2. From the remaining participating statuses: + - If any authorization has status "rejected" → consent status = "rejected" + - If any authorization has status "created" → consent status = "created" + - If all authorizations have status "approved" (or empty/default) → consent status = "active" + - For custom authorization states → consent status is resolved via extension point (must return a known status) **Purposes:** The purposes field is REQUIRED and will completely replace existing consent elements. diff --git a/consent-server/cmd/server/repository/conf/deployment.yaml b/consent-server/cmd/server/repository/conf/deployment.yaml index e96e1bdf..33e32e2d 100644 --- a/consent-server/cmd/server/repository/conf/deployment.yaml +++ b/consent-server/cmd/server/repository/conf/deployment.yaml @@ -52,6 +52,8 @@ consent: rejected_state: REJECTED # Authorization state indicating creation created_state: CREATED + # Authorization state for passive participants who don't need to approve (e.g., child, agent) + recorded_state: RECORDED # Authorization state indicating authorization was system-expired due to consent expiry system_expired_state: SYS_EXPIRED # Authorization state indicating authorization was system-revoked due to consent revocation diff --git a/consent-server/internal/authresource/model/auth_resource.go b/consent-server/internal/authresource/model/auth_resource.go index 14130c24..15a63fa3 100644 --- a/consent-server/internal/authresource/model/auth_resource.go +++ b/consent-server/internal/authresource/model/auth_resource.go @@ -22,8 +22,34 @@ package model const ( // DefaultAuthType is used when the caller does not specify an authorization type. DefaultAuthType = "default" + + // --- First-class authorization types for consent delegation --- + + // AuthTypePrimary indicates self-consent: the person consenting for themselves. + AuthTypePrimary = "primary" + + // AuthTypeDelegate indicates a person giving consent on behalf of another + // (e.g., a parent consenting for a child). + AuthTypeDelegate = "delegate" + + // AuthTypeDelegateSubject indicates a person who is incapable of providing + // consent by themselves (e.g., a minor child). + AuthTypeDelegateSubject = "delegate_subject" ) +// FirstClassAuthTypes is the set of auth types that OpenFGC validates. +// Custom types (anything not in this set) are stored and filterable but not validated. +var FirstClassAuthTypes = map[string]bool{ + AuthTypePrimary: true, + AuthTypeDelegate: true, + AuthTypeDelegateSubject: true, +} + +// IsFirstClassAuthType reports whether the given type is a recognized first-class auth type. +func IsFirstClassAuthType(authType string) bool { + return FirstClassAuthTypes[authType] +} + // ============================================================================= // DB types — store layer only, db tags, no json tags // ============================================================================= @@ -49,7 +75,7 @@ type AuthResource struct { // AuthType defaults to DefaultAuthType ("default") when empty. // AuthStatus defaults to the configured approved state when empty. type CreateAuthResourceInput struct { - AuthType string // optional; defaults to DefaultAuthType + AuthType string // optional; defaults to DefaultAuthType UserID *string AuthStatus string // optional; defaults to configured approved state Resources interface{} // arbitrary value; service JSON-marshals before storing @@ -94,8 +120,8 @@ type AuthResourceListOutput struct { // Type is optional — when absent the server uses DefaultAuthType ("default"). type AuthResourceCreateRequest struct { UserID *string `json:"userId,omitempty"` - Type string `json:"type,omitempty"` // optional; defaults to "default" - Status string `json:"status,omitempty"` // optional; defaults to "APPROVED" + Type string `json:"type,omitempty"` // optional; defaults to "default" + Status string `json:"status,omitempty"` // optional; defaults to "APPROVED" Resources interface{} `json:"resources,omitempty"` } diff --git a/consent-server/internal/consent/handler.go b/consent-server/internal/consent/handler.go index 4fd5e088..8547513b 100644 --- a/consent-server/internal/consent/handler.go +++ b/consent-server/internal/consent/handler.go @@ -215,6 +215,35 @@ func (h *consentHandler) listConsents(w http.ResponseWriter, r *http.Request) { filters.UserIDs = parts } + // delegation (boolean) — filters by delegation role when combined with userIds + // true = user is a delegate, false = user's own self-consents + if s := r.URL.Query().Get("delegation"); s != "" { + switch strings.ToLower(s) { + case "true": + v := true + filters.Delegation = &v + case "false": + v := false + filters.Delegation = &v + default: + utils.SendError(w, r, serviceerror.CustomServiceError(ErrorValidationFailed, + "delegation must be 'true' or 'false'")) + return + } + } + + // delegateSubject — filter consents where this userId is the delegate subject + filters.DelegateSubject = r.URL.Query().Get("delegateSubject") + + // authTypes — filter by specific auth type values (e.g., "agent", "carer") + if s := r.URL.Query().Get("authTypes"); s != "" { + parts := strings.Split(s, ",") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + filters.AuthTypes = parts + } + // purposeName (single) + optional purposeVersion filters.PurposeName = r.URL.Query().Get("purposeName") if s := r.URL.Query().Get("purposeVersion"); s != "" { @@ -262,6 +291,12 @@ func (h *consentHandler) listConsents(w http.ResponseWriter, r *http.Request) { "elementVersion requires elementName or elementNamespace to be specified")) return } + // delegation and authTypes are mutually exclusive — use one or the other + if filters.Delegation != nil && len(filters.AuthTypes) > 0 { + utils.SendError(w, r, serviceerror.CustomServiceError(ErrorValidationFailed, + "delegation and authTypes cannot be used together; use delegation for first-class types or authTypes for custom types")) + return + } listOut, serviceErr := h.service.SearchConsents(ctx, filters) if serviceErr != nil { diff --git a/consent-server/internal/consent/model/consent.go b/consent-server/internal/consent/model/consent.go index ad4d16bc..9430f0fb 100644 --- a/consent-server/internal/consent/model/consent.go +++ b/consent-server/internal/consent/model/consent.go @@ -184,6 +184,22 @@ type ConsentSearchFilter struct { Limit int Offset int OrgID string + + // --- Consent delegation search filters --- + + // Delegation filters consents by delegation role when combined with UserIDs. + // nil = not specified (return all consents for the user regardless of auth type). + // true = user is a delegate (auth type = "delegate"). + // false = user's own self-consents (auth type = "primary" or "default"). + Delegation *bool + + // DelegateSubject filters consents where this user ID is the delegate subject. + // When set, returns consents where this person's data is being consented for. + DelegateSubject string + + // AuthTypes filters by specific auth type values (e.g., "agent", "carer"). + // Supports both first-class and custom auth types. + AuthTypes []string } // ============================================================================= diff --git a/consent-server/internal/consent/store.go b/consent-server/internal/consent/store.go index 62532bfe..3383d6d5 100644 --- a/consent-server/internal/consent/store.go +++ b/consent-server/internal/consent/store.go @@ -825,15 +825,68 @@ func (s *store) Search(ctx context.Context, filters model.ConsentSearchFilter) ( } } - // UserIDs filter via JOIN — kept as JOIN so COUNT(DISTINCT) handles any duplicates. - if len(filters.UserIDs) > 0 { - ph := strings.Repeat("?,", len(filters.UserIDs)) + // ========================================================================= + // Auth resource filters (userIds, delegation, authTypes, delegateSubject) + // + // These use JOINs on CONSENT_AUTH_RESOURCE. When multiple filters are active + // they combine: + // - userIds + delegation/authTypes share a single JOIN (car) + // - delegateSubject uses its own JOIN (car_ds) so it can coexist with the + // userIds JOIN (e.g., "father is delegate AND son is delegate_subject") + // ========================================================================= + + // Primary auth resource JOIN — needed when userIds, delegation, or authTypes is set. + needsCarJoin := len(filters.UserIDs) > 0 || filters.Delegation != nil || len(filters.AuthTypes) > 0 + if needsCarJoin { joinClause += " INNER JOIN CONSENT_AUTH_RESOURCE car ON CONSENT.CONSENT_ID = car.CONSENT_ID AND CONSENT.ORG_ID = car.ORG_ID" - whereConditions = append(whereConditions, fmt.Sprintf("car.USER_ID IN (%s)", ph[:len(ph)-1])) - for _, uid := range filters.UserIDs { - args = append(args, uid) - countArgs = append(countArgs, uid) + + // UserIDs filter + if len(filters.UserIDs) > 0 { + ph := strings.Repeat("?,", len(filters.UserIDs)) + whereConditions = append(whereConditions, fmt.Sprintf("car.USER_ID IN (%s)", ph[:len(ph)-1])) + for _, uid := range filters.UserIDs { + args = append(args, uid) + countArgs = append(countArgs, uid) + } + } + + // Delegation filter on auth type (first-class types) + if filters.Delegation != nil { + if *filters.Delegation { + // delegation=true: consents where user is a delegate + whereConditions = append(whereConditions, "car.AUTH_TYPE = ?") + args = append(args, "delegate") + countArgs = append(countArgs, "delegate") + } else { + // delegation=false: user's own self-consents (primary or legacy default) + whereConditions = append(whereConditions, "car.AUTH_TYPE IN (?, ?)") + args = append(args, "primary", "default") + countArgs = append(countArgs, "primary", "default") + } } + + // AuthTypes filter (custom type filtering, e.g., "agent", "carer") + if len(filters.AuthTypes) > 0 { + ph := strings.Repeat("?,", len(filters.AuthTypes)) + whereConditions = append(whereConditions, fmt.Sprintf("car.AUTH_TYPE IN (%s)", ph[:len(ph)-1])) + for _, at := range filters.AuthTypes { + args = append(args, at) + countArgs = append(countArgs, at) + } + } + } + + // DelegateSubject filter — separate JOIN to find consents about a specific subject's data. + // Uses a separate alias (car_ds) so it can coexist with the primary car JOIN above. + // Example: ?delegation=true&userIds=father-111&delegateSubject=son-333 + // → car JOIN filters for father as delegate + // → car_ds JOIN filters for son as delegate_subject + if filters.DelegateSubject != "" { + joinClause += " INNER JOIN CONSENT_AUTH_RESOURCE car_ds ON CONSENT.CONSENT_ID = car_ds.CONSENT_ID AND CONSENT.ORG_ID = car_ds.ORG_ID" + whereConditions = append(whereConditions, "car_ds.USER_ID = ?") + whereConditions = append(whereConditions, "car_ds.AUTH_TYPE = ?") + args = append(args, filters.DelegateSubject, "delegate_subject") + countArgs = append(countArgs, filters.DelegateSubject, "delegate_subject") } // PurposeName filter via EXISTS subquery to avoid duplicate rows. diff --git a/consent-server/internal/consent/validator/consent.go b/consent-server/internal/consent/validator/consent.go index e92aa3ee..b848ba5a 100644 --- a/consent-server/internal/consent/validator/consent.go +++ b/consent-server/internal/consent/validator/consent.go @@ -23,6 +23,7 @@ import ( "strings" "time" + authmodel "github.com/wso2/openfgc/internal/authresource/model" authvalidator "github.com/wso2/openfgc/internal/authresource/validator" "github.com/wso2/openfgc/internal/consent/model" "github.com/wso2/openfgc/internal/system/config" @@ -85,6 +86,11 @@ func ValidateConsentCreateRequest(req model.ConsentCreateRequest, groupID, orgID } } + // Validate auth type constraints across the full authorization set + if err := ValidateAuthTypeConstraints(req.Authorizations); err != nil { + return err + } + return nil } @@ -134,6 +140,103 @@ func ValidateConsentUpdateRequest(req model.ConsentUpdateRequest) error { } } + // Validate auth type constraints if authorizations are being replaced + if req.Authorizations != nil { + if err := ValidateAuthTypeConstraints(req.Authorizations); err != nil { + return err + } + } + + return nil +} + +// ValidateAuthTypeConstraints validates the auth type rules across a full set of authorizations. +// +// Rules for first-class types: +// - "delegate" requires at least one "delegate_subject" in the same consent +// - "delegate_subject" requires at least one "delegate" in the same consent +// - "primary" cannot mix with "delegate" or "delegate_subject" +// +// Custom types (anything not primary/delegate/delegate_subject) skip these rules entirely. +// +// Universal rule (applies to all types): +// - At least one authorization must have a non-RECORDED status. +// This prevents consents where nobody actively consented. +func ValidateAuthTypeConstraints(authorizations []model.AuthorizationRequest) error { + if len(authorizations) == 0 { + return nil + } + + hasPrimary := false + hasDelegate := false + hasDelegateSubject := false + hasFirstClass := false + + cfg := config.Get() + + // Determine the recorded status string for the universal participation check + var recordedStatus string + if cfg != nil { + recordedStatus = string(cfg.Consent.GetRecordedAuthStatus()) + } + + hasNonRecorded := false + + for _, auth := range authorizations { + // Resolve the effective auth type (empty defaults to "default" which is treated like primary) + authType := auth.Type + if authType == "" { + authType = authmodel.DefaultAuthType + } + + switch authType { + case authmodel.AuthTypePrimary: + hasPrimary = true + hasFirstClass = true + case authmodel.AuthTypeDelegate: + hasDelegate = true + hasFirstClass = true + case authmodel.AuthTypeDelegateSubject: + hasDelegateSubject = true + hasFirstClass = true + case authmodel.DefaultAuthType: + // "default" is treated as self-consent (like primary) for validation purposes + hasPrimary = true + } + + // Universal participation check: at least one auth must not be RECORDED + effectiveStatus := auth.Status + if effectiveStatus == "" { + // Empty status defaults to APPROVED — that's a participating status + hasNonRecorded = true + } else if recordedStatus == "" || effectiveStatus != recordedStatus { + hasNonRecorded = true + } + } + + // Universal rule: at least one authorization must actively participate + if !hasNonRecorded { + return fmt.Errorf("at least one authorization must have an active status; RECORDED alone does not constitute consent") + } + + // First-class type pairing rules (only enforced when first-class types are present) + if hasFirstClass { + // primary cannot mix with delegate or delegate_subject + if hasPrimary && (hasDelegate || hasDelegateSubject) { + return fmt.Errorf("authorization type 'primary' cannot be mixed with 'delegate' or 'delegate_subject' in the same consent") + } + + // delegate requires at least one delegate_subject + if hasDelegate && !hasDelegateSubject { + return fmt.Errorf("authorization type 'delegate' requires at least one 'delegate_subject' in the same consent") + } + + // delegate_subject requires at least one delegate + if hasDelegateSubject && !hasDelegate { + return fmt.Errorf("authorization type 'delegate_subject' requires at least one 'delegate' in the same consent") + } + } + return nil } @@ -155,7 +258,17 @@ func ValidateConsentGetRequest(consentID, orgID string) error { } // EvaluateConsentStatusFromAuthStatuses determines consent status from a list of auth status strings. -// Priority: rejected > created > active. +// +// The derivation follows two steps: +// +// 1. Filter out non-participating statuses: RECORDED, SYS_EXPIRED, and SYS_REVOKED are +// excluded because they represent passive participants or system-managed states that +// should not influence the consent's overall status. +// +// 2. From the remaining participating statuses, apply priority logic: +// Any REJECTED → consent REJECTED +// Any CREATED → consent CREATED +// All APPROVED → consent ACTIVE func EvaluateConsentStatusFromAuthStatuses(authStatuses []string) string { cfg := config.Get() if cfg == nil { @@ -167,12 +280,35 @@ func EvaluateConsentStatusFromAuthStatuses(authStatuses []string) string { return string(consentConfig.GetCreatedConsentStatus()) } - // Evaluate ALL auth statuses with priority logic + // Step 1: Filter out non-participating statuses. + // RECORDED = passive participant (child, agent) — no action needed. + // SYS_EXPIRED / SYS_REVOKED = system-managed states — should not corrupt derivation. + recordedStatus := strings.ToUpper(string(consentConfig.GetRecordedAuthStatus())) + sysExpiredStatus := strings.ToUpper(string(consentConfig.GetSystemExpiredAuthStatus())) + sysRevokedStatus := strings.ToUpper(string(consentConfig.GetSystemRevokedAuthStatus())) + + participatingStatuses := make([]string, 0, len(authStatuses)) + for _, status := range authStatuses { + upper := strings.ToUpper(status) + if upper == recordedStatus || upper == sysExpiredStatus || upper == sysRevokedStatus { + continue + } + participatingStatuses = append(participatingStatuses, status) + } + + // If all statuses were filtered out but there were auth resources, + // this shouldn't happen in practice because validation ensures at least one + // non-RECORDED auth exists. Fall back to created as a safety net. + if len(participatingStatuses) == 0 { + return string(consentConfig.GetCreatedConsentStatus()) + } + + // Step 2: Evaluate participating statuses with priority logic hasRejected := false hasCreated := false allApproved := true - for _, authStatus := range authStatuses { + for _, authStatus := range participatingStatuses { // Map auth status to consent status first (case-insensitive comparison) authStatusUpper := strings.ToUpper(authStatus) var mappedConsentStatus string diff --git a/consent-server/internal/consent/validator/consent_test.go b/consent-server/internal/consent/validator/consent_test.go index f4798ca5..51318d72 100644 --- a/consent-server/internal/consent/validator/consent_test.go +++ b/consent-server/internal/consent/validator/consent_test.go @@ -276,9 +276,12 @@ func setTestConfig() { RejectedStatus: "REJECTED", }, AuthStatusMappings: config.AuthStatusMappings{ - ApprovedState: "APPROVED", - RejectedState: "REJECTED", - CreatedState: "CREATED", + ApprovedState: "APPROVED", + RejectedState: "REJECTED", + CreatedState: "CREATED", + RecordedState: "RECORDED", + SystemExpiredState: "SYS_EXPIRED", + SystemRevokedState: "SYS_REVOKED", }, }, }) @@ -360,3 +363,236 @@ func TestEvaluateConsentStatus_CaseInsensitive(t *testing.T) { got := EvaluateConsentStatusFromAuthStatuses([]string{"approved", "approved"}) require.Equal(t, "ACTIVE", got) } + +// ============================================================================= +// EvaluateConsentStatusFromAuthStatuses — RECORDED filtering +// ============================================================================= + +func TestEvaluateConsentStatus_RecordedIsSkipped(t *testing.T) { + setTestConfig() + // Parent APPROVED + child RECORDED → skip RECORDED → only APPROVED remains → ACTIVE + got := EvaluateConsentStatusFromAuthStatuses([]string{"APPROVED", "RECORDED"}) + require.Equal(t, "ACTIVE", got) +} + +func TestEvaluateConsentStatus_RecordedWithCreated(t *testing.T) { + setTestConfig() + // Delegate CREATED + child RECORDED → skip RECORDED → CREATED remains → CREATED + got := EvaluateConsentStatusFromAuthStatuses([]string{"CREATED", "RECORDED"}) + require.Equal(t, "CREATED", got) +} + +func TestEvaluateConsentStatus_RecordedWithRejected(t *testing.T) { + setTestConfig() + // Delegate REJECTED + child RECORDED → skip RECORDED → REJECTED remains → REJECTED + got := EvaluateConsentStatusFromAuthStatuses([]string{"REJECTED", "RECORDED"}) + require.Equal(t, "REJECTED", got) +} + +func TestEvaluateConsentStatus_MultipleRecorded(t *testing.T) { + setTestConfig() + // One APPROVED + multiple RECORDED → all RECORDED skipped → ACTIVE + got := EvaluateConsentStatusFromAuthStatuses([]string{"APPROVED", "RECORDED", "RECORDED"}) + require.Equal(t, "ACTIVE", got) +} + +func TestEvaluateConsentStatus_SysExpiredIsSkipped(t *testing.T) { + setTestConfig() + // SYS_EXPIRED should be filtered out, not treated as unknown/CREATED. + got := EvaluateConsentStatusFromAuthStatuses([]string{"APPROVED", "SYS_EXPIRED"}) + require.Equal(t, "ACTIVE", got) +} + +func TestEvaluateConsentStatus_SysRevokedIsSkipped(t *testing.T) { + setTestConfig() + // SYS_REVOKED should be filtered out, not treated as unknown/CREATED. + got := EvaluateConsentStatusFromAuthStatuses([]string{"APPROVED", "SYS_REVOKED"}) + require.Equal(t, "ACTIVE", got) +} + +func TestEvaluateConsentStatus_AllNonParticipating_FallsBackToCreated(t *testing.T) { + setTestConfig() + // If every status is filtered (RECORDED + SYS_EXPIRED), the safety fallback is CREATED. + // This shouldn't happen in practice because validation prevents all-RECORDED consents. + got := EvaluateConsentStatusFromAuthStatuses([]string{"RECORDED", "SYS_EXPIRED"}) + require.Equal(t, "CREATED", got) +} + +func TestEvaluateConsentStatus_RecordedCaseInsensitive(t *testing.T) { + setTestConfig() + // RECORDED filtering must be case-insensitive. + got := EvaluateConsentStatusFromAuthStatuses([]string{"APPROVED", "recorded"}) + require.Equal(t, "ACTIVE", got) +} + +func TestEvaluateConsentStatus_DelegationScenario_ParentChild(t *testing.T) { + setTestConfig() + // Realistic delegation: parent APPROVED, child RECORDED → ACTIVE + got := EvaluateConsentStatusFromAuthStatuses([]string{"APPROVED", "RECORDED"}) + require.Equal(t, "ACTIVE", got, "parent-child delegation: parent approved, child recorded → ACTIVE") +} + +func TestEvaluateConsentStatus_DelegationScenario_ParentPending(t *testing.T) { + setTestConfig() + // Parent hasn't approved yet (CREATED), child RECORDED → CREATED + got := EvaluateConsentStatusFromAuthStatuses([]string{"CREATED", "RECORDED"}) + require.Equal(t, "CREATED", got, "parent-child delegation: parent pending, child recorded → CREATED") +} + +func TestEvaluateConsentStatus_CustomTypeScenario_OwnerAndAgent(t *testing.T) { + setTestConfig() + // owner APPROVED + agent RECORDED → skip RECORDED → ACTIVE + got := EvaluateConsentStatusFromAuthStatuses([]string{"APPROVED", "RECORDED"}) + require.Equal(t, "ACTIVE", got, "custom types: owner approved, agent recorded → ACTIVE") +} + +// ============================================================================= +// ValidateAuthTypeConstraints +// ============================================================================= + +func TestValidateAuthTypeConstraints_EmptyList(t *testing.T) { + setTestConfig() + // No authorizations — nothing to validate, passes. + err := ValidateAuthTypeConstraints(nil) + require.NoError(t, err) +} + +func TestValidateAuthTypeConstraints_PrimaryOnly(t *testing.T) { + setTestConfig() + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "user-1", Type: "primary", Status: "APPROVED"}, + }) + require.NoError(t, err) +} + +func TestValidateAuthTypeConstraints_DefaultTreatedAsPrimary(t *testing.T) { + setTestConfig() + // Empty type defaults to "default" which is treated as self-consent (like primary). + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "user-1", Status: "APPROVED"}, + }) + require.NoError(t, err) +} + +func TestValidateAuthTypeConstraints_DelegateWithDelegateSubject(t *testing.T) { + setTestConfig() + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }) + require.NoError(t, err) +} + +func TestValidateAuthTypeConstraints_DelegateWithoutDelegateSubject(t *testing.T) { + setTestConfig() + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "'delegate' requires at least one 'delegate_subject'") +} + +func TestValidateAuthTypeConstraints_DelegateSubjectWithoutDelegate(t *testing.T) { + setTestConfig() + // This also fails the universal rule (only RECORDED = no active participant), + // but the first-class pairing check fires first. + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }) + require.Error(t, err) + // Should hit either the universal rule or the pairing rule +} + +func TestValidateAuthTypeConstraints_PrimaryMixedWithDelegate(t *testing.T) { + setTestConfig() + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "user-1", Type: "primary", Status: "APPROVED"}, + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "'primary' cannot be mixed with 'delegate' or 'delegate_subject'") +} + +func TestValidateAuthTypeConstraints_PrimaryMixedWithDelegateSubject(t *testing.T) { + setTestConfig() + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "user-1", Type: "primary", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "'primary' cannot be mixed with 'delegate' or 'delegate_subject'") +} + +func TestValidateAuthTypeConstraints_CustomTypesSkipValidation(t *testing.T) { + setTestConfig() + // Custom types "owner" and "agent" skip first-class pairing rules entirely. + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "user-111", Type: "owner", Status: "APPROVED"}, + {UserID: "agent-ai", Type: "agent", Status: "RECORDED"}, + }) + require.NoError(t, err) +} + +func TestValidateAuthTypeConstraints_AllRecordedRejected(t *testing.T) { + setTestConfig() + // Universal rule: at least one authorization must have a non-RECORDED status. + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "agent-ai", Type: "agent", Status: "RECORDED"}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "at least one authorization must have an active status") +} + +func TestValidateAuthTypeConstraints_AllRecordedMultiple(t *testing.T) { + setTestConfig() + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "agent-1", Type: "agent", Status: "RECORDED"}, + {UserID: "agent-2", Type: "carer", Status: "RECORDED"}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "at least one authorization must have an active status") +} + +func TestValidateAuthTypeConstraints_CustomWithApprovedAndRecorded(t *testing.T) { + setTestConfig() + // owner:APPROVED + agent:RECORDED → passes both universal and custom rules + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "user-111", Type: "owner", Status: "APPROVED"}, + {UserID: "agent-ai", Type: "agent", Status: "RECORDED"}, + }) + require.NoError(t, err) +} + +func TestValidateAuthTypeConstraints_EmptyStatusDefaultsToApproved(t *testing.T) { + setTestConfig() + // Empty status defaults to APPROVED at the service layer, so it counts as non-RECORDED. + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "user-1", Type: "primary"}, + }) + require.NoError(t, err) +} + +func TestValidateAuthTypeConstraints_MultipleDelegatesAndSubjects(t *testing.T) { + setTestConfig() + // Multiple delegates + multiple subjects is valid. + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "parent-1", Type: "delegate", Status: "APPROVED"}, + {UserID: "parent-2", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-1", Type: "delegate_subject", Status: "RECORDED"}, + {UserID: "child-2", Type: "delegate_subject", Status: "RECORDED"}, + }) + require.NoError(t, err) +} + +func TestValidateAuthTypeConstraints_DefaultMixedWithDelegate(t *testing.T) { + setTestConfig() + // "default" is treated as primary, so mixing with delegate should fail. + err := ValidateAuthTypeConstraints([]model.AuthorizationRequest{ + {UserID: "user-1", Status: "APPROVED"}, // type="" → "default" → primary + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, // delegate + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, // delegate_subject + }) + require.Error(t, err) + require.Contains(t, err.Error(), "'primary' cannot be mixed with 'delegate'") +} diff --git a/consent-server/internal/system/config/config.go b/consent-server/internal/system/config/config.go index 6cf45a3f..4f3085b5 100644 --- a/consent-server/internal/system/config/config.go +++ b/consent-server/internal/system/config/config.go @@ -121,6 +121,7 @@ type AuthStatusMappings struct { ApprovedState string `yaml:"approved_state"` RejectedState string `yaml:"rejected_state"` CreatedState string `yaml:"created_state"` + RecordedState string `yaml:"recorded_state"` SystemExpiredState string `yaml:"system_expired_state"` SystemRevokedState string `yaml:"system_revoked_state"` } @@ -165,6 +166,13 @@ func (c *ConsentConfig) GetCreatedAuthStatus() AuthStatus { return AuthStatus(c.AuthStatusMappings.CreatedState) } +// GetRecordedAuthStatus returns the typed recorded auth status from config. +// RECORDED means "this person is recorded in the consent but no action is needed from them" +// (e.g., a child in a parent-child delegation, or an AI agent). +func (c *ConsentConfig) GetRecordedAuthStatus() AuthStatus { + return AuthStatus(c.AuthStatusMappings.RecordedState) +} + // GetSystemExpiredAuthStatus returns the typed system expired auth status from config func (c *ConsentConfig) GetSystemExpiredAuthStatus() AuthStatus { return AuthStatus(c.AuthStatusMappings.SystemExpiredState) @@ -350,6 +358,9 @@ func validateConfig(config *Config) error { if config.Consent.AuthStatusMappings.CreatedState == "" { return fmt.Errorf("auth created status mapping is required") } + if config.Consent.AuthStatusMappings.RecordedState == "" { + return fmt.Errorf("auth recorded status mapping is required") + } if config.Consent.AuthStatusMappings.SystemExpiredState == "" { return fmt.Errorf("auth system expired status mapping is required") } diff --git a/tests/integration/consent/delegation_test.go b/tests/integration/consent/delegation_test.go new file mode 100644 index 00000000..10296084 --- /dev/null +++ b/tests/integration/consent/delegation_test.go @@ -0,0 +1,546 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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 + * + * http://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 consent + +import ( + "encoding/json" + "net/http" + "net/url" +) + +// ============================================================================= +// TestCreateConsentDelegation covers POST /consents with delegation auth types. +// ============================================================================= + +func (ts *ConsentAPITestSuite) TestCreateConsentDelegation() { + type testCase struct { + name string + groupID string + buildBody func(orgID string) any + wantStatus int + wantErrorCode string + checkResult func(resp *ConsentResponse) + } + + cases := []testCase{ + + { + name: "delegation: delegate + delegate_subject with RECORDED → ACTIVE", + groupID: "grp-deleg-1", + buildBody: func(_ string) any { + return ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + } + }, + wantStatus: http.StatusCreated, + checkResult: func(resp *ConsentResponse) { + ts.Equal("ACTIVE", resp.Status, + "delegate APPROVED + delegate_subject RECORDED → consent ACTIVE") + ts.Require().Len(resp.Authorizations, 2) + }, + }, + { + name: "delegation: delegate CREATED + delegate_subject RECORDED → CREATED", + groupID: "grp-deleg-pending", + buildBody: func(_ string) any { + return ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "CREATED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + } + }, + wantStatus: http.StatusCreated, + checkResult: func(resp *ConsentResponse) { + ts.Equal("CREATED", resp.Status, + "delegate CREATED + delegate_subject RECORDED → consent stays CREATED") + }, + }, + { + name: "self-consent: primary with APPROVED → ACTIVE", + groupID: "grp-primary", + buildBody: func(_ string) any { + return ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "user-111", Type: "primary", Status: "APPROVED"}, + }, + } + }, + wantStatus: http.StatusCreated, + checkResult: func(resp *ConsentResponse) { + ts.Equal("ACTIVE", resp.Status) + ts.Require().Len(resp.Authorizations, 1) + ts.Equal("primary", resp.Authorizations[0].Type) + }, + }, + { + name: "custom types: owner APPROVED + agent RECORDED → ACTIVE", + groupID: "grp-custom", + buildBody: func(_ string) any { + return ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "user-111", Type: "owner", Status: "APPROVED"}, + {UserID: "agent-ai", Type: "agent", Status: "RECORDED"}, + }, + } + }, + wantStatus: http.StatusCreated, + checkResult: func(resp *ConsentResponse) { + ts.Equal("ACTIVE", resp.Status, + "custom types: owner APPROVED + agent RECORDED → ACTIVE") + ts.Require().Len(resp.Authorizations, 2) + }, + }, + { + name: "multiple delegates + multiple subjects → ACTIVE", + groupID: "grp-multi-deleg", + buildBody: func(_ string) any { + return ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "parent-1", Type: "delegate", Status: "APPROVED"}, + {UserID: "parent-2", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-1", Type: "delegate_subject", Status: "RECORDED"}, + {UserID: "child-2", Type: "delegate_subject", Status: "RECORDED"}, + }, + } + }, + wantStatus: http.StatusCreated, + checkResult: func(resp *ConsentResponse) { + ts.Equal("ACTIVE", resp.Status) + ts.Require().Len(resp.Authorizations, 4) + }, + }, + + // ----------------------------------------------------------------- + // Validation errors + // ----------------------------------------------------------------- + { + name: "delegate without delegate_subject → 400", + groupID: "grp-deleg-err-1", + buildBody: func(_ string) any { + return ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + }, + } + }, + wantStatus: http.StatusBadRequest, + wantErrorCode: "CS-4002", + }, + { + name: "delegate_subject without delegate → 400", + groupID: "grp-deleg-err-2", + buildBody: func(_ string) any { + return ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + } + }, + wantStatus: http.StatusBadRequest, + wantErrorCode: "CS-4002", + }, + { + name: "primary mixed with delegate → 400", + groupID: "grp-deleg-err-3", + buildBody: func(_ string) any { + return ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "user-111", Type: "primary", Status: "APPROVED"}, + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + } + }, + wantStatus: http.StatusBadRequest, + wantErrorCode: "CS-4002", + }, + { + name: "all RECORDED (agent only) → 400", + groupID: "grp-deleg-err-4", + buildBody: func(_ string) any { + return ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "agent-ai", Type: "agent", Status: "RECORDED"}, + }, + } + }, + wantStatus: http.StatusBadRequest, + wantErrorCode: "CS-4002", + }, + } + + for _, tc := range cases { + tc := tc + ts.Run(tc.name, func() { + orgID := freshOrgID() + body := tc.buildBody(orgID) + + status, respBody := ts.doCreateConsentRaw(orgID, tc.groupID, body) + ts.Require().Equal(tc.wantStatus, status, "unexpected status; body: %s", respBody) + + if tc.wantErrorCode != "" { + ts.assertAPIError(respBody, tc.wantErrorCode) + return + } + + var resp ConsentResponse + ts.Require().NoError(json.Unmarshal(respBody, &resp)) + if tc.checkResult != nil { + tc.checkResult(&resp) + } + }) + } +} + +// ============================================================================= +// TestSearchConsentsDelegation covers GET /consents with delegation query params. +// ============================================================================= + +func (ts *ConsentAPITestSuite) TestSearchConsentsDelegation() { + type testCase struct { + name string + setup func(orgID string) + params url.Values + wantStatus int + wantError string + checkResult func(resp *ConsentListResponse) + } + + cases := []testCase{ + + // ----------------------------------------------------------------- + // delegation=false — self-consents only + // ----------------------------------------------------------------- + { + name: "delegation=false — returns only self-consents for the user", + setup: func(orgID string) { + // Father's self-consent (primary) + ts.mustCreateConsent(orgID, "grp-self-1", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "primary", Status: "APPROVED"}, + }, + }) + // Father is a delegate on another consent — must NOT appear + ts.mustCreateConsent(orgID, "grp-deleg-1", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + }) + }, + params: url.Values{"delegation": {"false"}, "userIds": {"father-111"}}, + wantStatus: http.StatusOK, + checkResult: func(resp *ConsentListResponse) { + ts.Equal(1, resp.Metadata.Total, "only self-consent should be returned") + ts.Require().Len(resp.Data, 1) + // The returned consent should have a primary auth for father + found := false + for _, auth := range resp.Data[0].Authorizations { + if auth.UserID != nil && *auth.UserID == "father-111" && auth.Type == "primary" { + found = true + } + } + ts.True(found, "returned consent must have father as primary") + }, + }, + + // ----------------------------------------------------------------- + // delegation=true — delegation consents + // ----------------------------------------------------------------- + { + name: "delegation=true — returns consents where user is a delegate", + setup: func(orgID string) { + // Father's self-consent — must NOT appear + ts.mustCreateConsent(orgID, "grp-self-2", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "primary", Status: "APPROVED"}, + }, + }) + // Father is a delegate + ts.mustCreateConsent(orgID, "grp-deleg-2", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + }) + }, + params: url.Values{"delegation": {"true"}, "userIds": {"father-111"}}, + wantStatus: http.StatusOK, + checkResult: func(resp *ConsentListResponse) { + ts.Equal(1, resp.Metadata.Total, "only delegation consent should be returned") + ts.Require().Len(resp.Data, 1) + // The returned consent should have father as delegate + found := false + for _, auth := range resp.Data[0].Authorizations { + if auth.UserID != nil && *auth.UserID == "father-111" && auth.Type == "delegate" { + found = true + } + } + ts.True(found, "returned consent must have father as delegate") + }, + }, + + // ----------------------------------------------------------------- + // delegateSubject — consents about a subject's data + // ----------------------------------------------------------------- + { + name: "delegateSubject — returns consents where this user is the subject", + setup: func(orgID string) { + // Consent about child-333's data + ts.mustCreateConsent(orgID, "grp-ds-1", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + }) + // Consent about child-444's data — must NOT appear + ts.mustCreateConsent(orgID, "grp-ds-2", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "mother-222", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-444", Type: "delegate_subject", Status: "RECORDED"}, + }, + }) + }, + params: url.Values{"delegateSubject": {"child-333"}}, + wantStatus: http.StatusOK, + checkResult: func(resp *ConsentListResponse) { + ts.Equal(1, resp.Metadata.Total, "only consents about child-333 should be returned") + }, + }, + + // ----------------------------------------------------------------- + // Combined: delegation=true + delegateSubject + // ----------------------------------------------------------------- + { + name: "delegation=true + delegateSubject — father manages son's consent", + setup: func(orgID string) { + // Father delegates for child-333 + ts.mustCreateConsent(orgID, "grp-combo-ds-1", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + }) + // Mother delegates for child-333 — must NOT appear (different delegate) + ts.mustCreateConsent(orgID, "grp-combo-ds-2", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "mother-222", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + }) + }, + params: url.Values{ + "delegation": {"true"}, + "userIds": {"father-111"}, + "delegateSubject": {"child-333"}, + }, + wantStatus: http.StatusOK, + checkResult: func(resp *ConsentListResponse) { + ts.Equal(1, resp.Metadata.Total, + "only consent where father is delegate AND child-333 is subject") + }, + }, + + // ----------------------------------------------------------------- + // authTypes — custom type filtering + // ----------------------------------------------------------------- + { + name: "authTypes=agent — returns consents with agent auth type", + setup: func(orgID string) { + // Consent with agent + ts.mustCreateConsent(orgID, "grp-at-1", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "user-111", Type: "owner", Status: "APPROVED"}, + {UserID: "agent-ai", Type: "agent", Status: "RECORDED"}, + }, + }) + // Consent without agent — must NOT appear + ts.mustCreateConsent(orgID, "grp-at-2", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "user-222", Type: "primary", Status: "APPROVED"}, + }, + }) + }, + params: url.Values{"authTypes": {"agent"}, "userIds": {"agent-ai"}}, + wantStatus: http.StatusOK, + checkResult: func(resp *ConsentListResponse) { + ts.Equal(1, resp.Metadata.Total, "only consent with agent-ai as agent") + }, + }, + + // ----------------------------------------------------------------- + // delegation=true without userIds — all delegation consents in system + // ----------------------------------------------------------------- + { + name: "delegation=true without userIds — all delegation consents", + setup: func(orgID string) { + // Delegation consent + ts.mustCreateConsent(orgID, "grp-all-deleg-1", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + }) + // Self-consent — must NOT appear + ts.mustCreateConsent(orgID, "grp-all-self-1", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "user-111", Type: "primary", Status: "APPROVED"}, + }, + }) + }, + params: url.Values{"delegation": {"true"}}, + wantStatus: http.StatusOK, + checkResult: func(resp *ConsentListResponse) { + ts.Equal(1, resp.Metadata.Total, "only delegation consent should appear") + }, + }, + + // ----------------------------------------------------------------- + // delegation=false without userIds — all self-consents in system + // ----------------------------------------------------------------- + { + name: "delegation=false without userIds — all self-consents", + setup: func(orgID string) { + // Self-consent + ts.mustCreateConsent(orgID, "grp-all-self-2", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "user-111", Type: "primary", Status: "APPROVED"}, + }, + }) + // Delegation consent — must NOT appear + ts.mustCreateConsent(orgID, "grp-all-deleg-2", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + }) + }, + params: url.Values{"delegation": {"false"}}, + wantStatus: http.StatusOK, + checkResult: func(resp *ConsentListResponse) { + ts.Equal(1, resp.Metadata.Total, "only self-consent should appear") + }, + }, + + // ----------------------------------------------------------------- + // userIds without delegation — all consents involving user (any role) + // ----------------------------------------------------------------- + { + name: "userIds without delegation — returns all consents involving the user", + setup: func(orgID string) { + // Father's self-consent + ts.mustCreateConsent(orgID, "grp-all-1", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "primary", Status: "APPROVED"}, + }, + }) + // Father as delegate + ts.mustCreateConsent(orgID, "grp-all-2", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "father-111", Type: "delegate", Status: "APPROVED"}, + {UserID: "child-333", Type: "delegate_subject", Status: "RECORDED"}, + }, + }) + // Unrelated consent — must NOT appear + ts.mustCreateConsent(orgID, "grp-all-3", ConsentCreateRequest{ + Type: "accounts", + Authorizations: []AuthorizationRequest{ + {UserID: "other-user", Type: "primary", Status: "APPROVED"}, + }, + }) + }, + params: url.Values{"userIds": {"father-111"}}, + wantStatus: http.StatusOK, + checkResult: func(resp *ConsentListResponse) { + ts.Equal(2, resp.Metadata.Total, + "both self-consent and delegation consent should appear") + }, + }, + + // ----------------------------------------------------------------- + // Validation errors + // ----------------------------------------------------------------- + { + name: "delegation invalid value → 400", + params: url.Values{"delegation": {"maybe"}}, + wantStatus: http.StatusBadRequest, + wantError: "CS-4002", + }, + { + name: "delegation + authTypes together → 400 (mutually exclusive)", + params: url.Values{"delegation": {"true"}, "authTypes": {"agent"}, "userIds": {"user-1"}}, + wantStatus: http.StatusBadRequest, + wantError: "CS-4002", + }, + } + + for _, tc := range cases { + tc := tc + ts.Run(tc.name, func() { + orgID := freshOrgID() + + if tc.setup != nil { + tc.setup(orgID) + } + + status, body := ts.doSearchConsents(orgID, tc.params) + ts.Require().Equal(tc.wantStatus, status, "unexpected status; body: %s", body) + + if tc.wantError != "" { + ts.assertAPIError(body, tc.wantError) + return + } + + var resp ConsentListResponse + ts.Require().NoError(json.Unmarshal(body, &resp), "unmarshal: %s", body) + if tc.checkResult != nil { + tc.checkResult(&resp) + } + }) + } +} diff --git a/tests/integration/repository/conf/deployment-sqlite.yaml b/tests/integration/repository/conf/deployment-sqlite.yaml index c9041fcf..07ead049 100644 --- a/tests/integration/repository/conf/deployment-sqlite.yaml +++ b/tests/integration/repository/conf/deployment-sqlite.yaml @@ -32,6 +32,7 @@ consent: approved_state: APPROVED rejected_state: REJECTED created_state: CREATED + recorded_state: RECORDED system_expired_state: SYS_EXPIRED system_revoked_state: SYS_REVOKED history: diff --git a/tests/integration/repository/conf/deployment.yaml b/tests/integration/repository/conf/deployment.yaml index 1cf9ca7e..29f9d667 100644 --- a/tests/integration/repository/conf/deployment.yaml +++ b/tests/integration/repository/conf/deployment.yaml @@ -35,6 +35,7 @@ consent: approved_state: APPROVED rejected_state: REJECTED created_state: CREATED + recorded_state: RECORDED system_expired_state: SYS_EXPIRED system_revoked_state: SYS_REVOKED history: