diff --git a/backend/cmd/server/bootstrap/01-default-resources.yaml b/backend/cmd/server/bootstrap/01-default-resources.yaml index 6ae5ae5581..a53e11ab3c 100644 --- a/backend/cmd/server/bootstrap/01-default-resources.yaml +++ b/backend/cmd/server/bootstrap/01-default-resources.yaml @@ -102,38 +102,6 @@ resources: - name: System handle: system description: System resource - - name: Organization Unit - handle: ou - description: Organization unit resource - parent: system - actions: - - name: View - handle: view - description: Read-only access to organization units - - name: User - handle: user - description: User resource - parent: system - actions: - - name: View - handle: view - description: Read-only access to users - - name: Group - handle: group - description: Group resource - parent: system - actions: - - name: View - handle: view - description: Read-only access to groups - - name: User Type - handle: usertype - description: User type resource - parent: system - actions: - - name: View - handle: view - description: Read-only access to user types --- resource_type: flow id: 01900000-0000-7000-8000-000000000061 diff --git a/docs/content/deployment/configuration.mdx b/docs/content/deployment/configuration.mdx index 19a0d73816..ebf626dd6e 100644 --- a/docs/content/deployment/configuration.mdx +++ b/docs/content/deployment/configuration.mdx @@ -926,7 +926,7 @@ Controls server-wide security behavior that is not specific to any single authen | Setting | Default | Description | |---------|---------|-------------| | `server.security.jwks_cache_ttl` | `300` | JWKS cache TTL in seconds. Applies to every JWKS consumer in the server (trusted issuer validation, federated OIDC authenticators such as Google, and so on). Fetched signing keys are reused from the in-process cache for this duration before being re-fetched. Plan external-server key rotations with at least this much overlap. Set to `0` to disable caching | -| `server.security.system_permission_prefix` | `""` (empty) | Prefix for system permission strings used in API authorization. When empty, permissions use their base names (for example, `system`, `system:ou`). When set, the prefix is prepended to every system permission (for example, `mgmt:system`, `mgmt:system:ou`). If you set a prefix, update the Console scopes to match. Changes require a server restart | +| `server.security.system_permission_prefix` | `""` (empty) | Prefix for system permission strings used in API authorization. When empty, permissions use their base names (for example, `system`). When set, the prefix is prepended to every system permission (for example, `mgmt:system`). If you set a prefix, update the Console scopes to match. Changes require a server restart | | `server.security.direct_auth_secret` | `""` (empty) | Secret that gates the Direct API authentication endpoints (`/auth/**` and `/register/passkey/**`) and protected AuthZEN access endpoints (`/access/**`). AuthZEN discovery (`/.well-known/authzen-configuration`) remains public. The protected endpoints are **secure by default**. While this is empty they are blocked with `401`. When set, callers must send the value in the `Direct-Auth-Secret` header; a missing or incorrect value is rejected with `401`. See [Integration Models](../../key-concepts/authentication/integration-models#direct-api) | :::tip diff --git a/docs/content/guides/trusted-issuer.mdx b/docs/content/guides/trusted-issuer.mdx index 151f18918e..eb3c380af7 100644 --- a/docs/content/guides/trusted-issuer.mdx +++ b/docs/content/guides/trusted-issuer.mdx @@ -69,7 +69,7 @@ window.__THUNDERID_RUNTIME_CONFIG__ = { http_only: false, public_url: "https://auth.example.com", client_id: "FEDERATED_CONSOLE", - scopes: ['openid', 'profile', 'email', 'group', 'ou', 'system', 'system:user', 'system:group', 'system:ou:view', 'system:usertype:view'], + scopes: ['openid', 'profile', 'email', 'group', 'ou', 'system'], type: "generic", }, }; @@ -158,7 +158,7 @@ configuration: httpOnly: false publicUrl: "https://auth.example.com" clientId: "FEDERATED_CONSOLE" - scopes: "['openid', 'profile', 'email', 'group', 'ou', 'system', 'system:user', 'system:group', 'system:ou:view', 'system:usertype:view']" + scopes: "['openid', 'profile', 'email', 'group', 'ou', 'system']" type: "generic" ``` diff --git a/frontend/apps/console/public/config.js b/frontend/apps/console/public/config.js index 2662443021..08edef9fc3 100644 --- a/frontend/apps/console/public/config.js +++ b/frontend/apps/console/public/config.js @@ -32,17 +32,7 @@ window.__THUNDERID_RUNTIME_CONFIG__ = { base: '/console', client_id: 'CONSOLE', resource_identifier: 'https://localhost:8090/mcp', - scopes: [ - 'openid', - 'profile', - 'email', - 'ou', - 'system', - 'system:user', - 'system:group', - 'system:ou:view', - 'system:usertype:view', - ], + scopes: ['openid', 'profile', 'email', 'ou', 'system'], }, // Defaults to the origin this app is served from. Add a `server` block with `public_url` // (or `hostname`, `port`, `http_only`) to target a different backend. diff --git a/install/helm/values.yaml b/install/helm/values.yaml index 51e6f5a00a..455904e888 100644 --- a/install/helm/values.yaml +++ b/install/helm/values.yaml @@ -201,7 +201,7 @@ configuration: consoleClient: path: "/console" clientId: "CONSOLE" - scopes: "['openid', 'profile', 'email', 'ou', 'system', 'system:user', 'system:group', 'system:ou:view', 'system:usertype:view']" + scopes: "['openid', 'profile', 'email', 'group', 'ou', 'system']" # Resource indicator (audience) the console sends on token requests. Must match the # identifier of the System resource server seeded by the bootstrap defaults, which is # "/mcp". Leave empty to derive it from server.publicUrl (default); @@ -216,7 +216,7 @@ configuration: # httpOnly: false # publicUrl: "https://cp.example.com" # clientId: "FEDERATED_CONSOLE" - # scopes: "['openid', 'profile', 'email', 'group', 'ou', 'system', 'system:user', 'system:group', 'system:ou:view', 'system:usertype:view']" + # scopes: "['openid', 'profile', 'email', 'group', 'ou', 'system']" # # Type of external authorization server. Set to `generic` when the trusted # # issuer is a generic OIDC provider. When `generic`, the console skips # # ThunderID-specific bootstrap calls (flow metadata, branding preferences) diff --git a/install/openchoreo/thunderid-oc-resourcetype/README.md b/install/openchoreo/thunderid-oc-resourcetype/README.md index 6a1a52a58f..34821cdfb5 100644 --- a/install/openchoreo/thunderid-oc-resourcetype/README.md +++ b/install/openchoreo/thunderid-oc-resourcetype/README.md @@ -356,7 +356,7 @@ A second `HTTPRoute` is rendered for that hostname and the Console's | `runtime.gate.clientBase` | Gate frontend base path | `/gate` | | `runtime.console.clientBase` | Console frontend base path | `/console` | | `runtime.console.clientId` | Console OAuth client ID | `CONSOLE` | -| `runtime.console.scopes` | Console OAuth scopes (JSON array string) — the default covers the management scopes the Console requests | `["openid", "profile", "email", "ou", "system", "system:user", "system:group", "system:ou:view", "system:usertype:view"]` | +| `runtime.console.scopes` | Console OAuth scopes (JSON array string). The default covers the management scopes the Console requests | `["openid", "profile", "email", "group", "ou", "system"]` | | `runtime.jwt.validityPeriod` | JWT validity in seconds | `3600` | | `runtime.oauth.refreshTokenValidityPeriod` | Refresh token validity in seconds | `86400` | | `runtime.cache.size` | Maximum in-memory cache entries | `10000` | diff --git a/install/openchoreo/thunderid-oc-resourcetype/samples/resource.yaml b/install/openchoreo/thunderid-oc-resourcetype/samples/resource.yaml index f1469fcc25..57056b3f43 100644 --- a/install/openchoreo/thunderid-oc-resourcetype/samples/resource.yaml +++ b/install/openchoreo/thunderid-oc-resourcetype/samples/resource.yaml @@ -129,7 +129,7 @@ spec: # console: # clientBase: "/console" # clientId: "CONSOLE" - # scopes: '["openid", "profile", "email", "ou", "system", "system:user", "system:group", "system:ou:view", "system:usertype:view"]' + # scopes: '["openid", "profile", "email", "group", "ou", "system"]' # jwt: # validityPeriod: 3600 # seconds # oauth: diff --git a/install/openchoreo/thunderid-oc-resourcetype/templates/thunderid-resourcetype.yaml b/install/openchoreo/thunderid-oc-resourcetype/templates/thunderid-resourcetype.yaml index 4921c59e09..4efa0cb711 100644 --- a/install/openchoreo/thunderid-oc-resourcetype/templates/thunderid-resourcetype.yaml +++ b/install/openchoreo/thunderid-oc-resourcetype/templates/thunderid-resourcetype.yaml @@ -217,7 +217,7 @@ spec: clientId: { type: string, default: "CONSOLE" } # Must cover the management scopes the Console requests — # matches frontend/apps/console/public/config.js. - scopes: { type: string, default: "[\"openid\", \"profile\", \"email\", \"ou\", \"system\", \"system:user\", \"system:group\", \"system:ou:view\", \"system:usertype:view\"]" } + scopes: { type: string, default: "[\"openid\", \"profile\", \"email\", \"group\", \"ou\", \"system\"]" } # Resource indicator (RFC 8707) the Console sends on token requests. # Must match the System resource_server identifier in declarativeResources. # Empty (default) derives it as "/mcp"; set to override. diff --git a/tests/integration/group/group_authz_test.go b/tests/integration/group/group_authz_test.go index f61ec3e8e6..b9824bcf3c 100644 --- a/tests/integration/group/group_authz_test.go +++ b/tests/integration/group/group_authz_test.go @@ -26,8 +26,8 @@ import ( "net/http" "testing" - "github.com/thunder-id/thunderid/tests/integration/testutils" "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" ) // GroupAuthzTestSuite validates that group CRUD operations respect OU-scoped authz. @@ -63,6 +63,7 @@ type GroupAuthzTestSuite struct { // Test role and manager groupMgrRoleID string groupMgrUserID string + scopedRSID string targetGroupOU1ID string deletableGroupOU1ID string targetGroupOU2ID string @@ -126,7 +127,7 @@ func (ts *GroupAuthzTestSuite) SetupSuite() { // ---- 2. Create user type for user-manager in OU1 ---- schemaOU1ID, err := testutils.CreateUserType(testutils.UserType{ - Name: entityTypeOU1Name, + Name: entityTypeOU1Name, OUID: ts.groupOU1ID, Schema: map[string]interface{}{ "username": map[string]interface{}{"type": "string"}, @@ -139,7 +140,7 @@ func (ts *GroupAuthzTestSuite) SetupSuite() { // ---- 3. Create the user-manager in OU1 ---- userMgrID, err := testutils.CreateUser(testutils.User{ - Type: entityTypeOU1Name, + Type: entityTypeOU1Name, OUID: ts.groupOU1ID, Attributes: json.RawMessage(fmt.Sprintf( `{"username": %q, "password": %q, "display_name": "Group Manager"}`, @@ -151,7 +152,7 @@ func (ts *GroupAuthzTestSuite) SetupSuite() { // ---- 3b. Create a plain member user in OU1 (used in membership authz tests) ---- memberOU1ID, err := testutils.CreateUser(testutils.User{ - Type: entityTypeOU1Name, + Type: entityTypeOU1Name, OUID: ts.groupOU1ID, Attributes: json.RawMessage(fmt.Sprintf( `{"username": %q, "password": %q, "display_name": "Member OU1"}`, @@ -163,7 +164,7 @@ func (ts *GroupAuthzTestSuite) SetupSuite() { // ---- 3c. Create a user type for OU2 ---- schemaOU2ID, err := testutils.CreateUserType(testutils.UserType{ - Name: memberSchemaOU2Name, + Name: memberSchemaOU2Name, OUID: ts.groupOU2ID, Schema: map[string]interface{}{ "username": map[string]interface{}{"type": "string"}, @@ -176,7 +177,7 @@ func (ts *GroupAuthzTestSuite) SetupSuite() { // ---- 3d. Create a plain member user in OU2 (used in membership authz tests) ---- memberOU2ID, err := testutils.CreateUser(testutils.User{ - Type: memberSchemaOU2Name, + Type: memberSchemaOU2Name, OUID: ts.groupOU2ID, Attributes: json.RawMessage(fmt.Sprintf( `{"username": %q, "password": %q, "display_name": "Member OU2"}`, @@ -188,36 +189,42 @@ func (ts *GroupAuthzTestSuite) SetupSuite() { // ---- 4. Create target groups ---- targetOU1ID, err := testutils.CreateGroup(testutils.Group{ - Name: "authz-target-ou1", - Description: "Target Group OU1", - OUID: ts.groupOU1ID, + Name: "authz-target-ou1", + Description: "Target Group OU1", + OUID: ts.groupOU1ID, }) ts.Require().NoError(err, "create target group in OU1") ts.targetGroupOU1ID = targetOU1ID deletableID, err := testutils.CreateGroup(testutils.Group{ - Name: "authz-deletable-ou1", - Description: "Deletable Group OU1", - OUID: ts.groupOU1ID, + Name: "authz-deletable-ou1", + Description: "Deletable Group OU1", + OUID: ts.groupOU1ID, }) ts.Require().NoError(err, "create deletable group in OU1") ts.deletableGroupOU1ID = deletableID targetOU2ID, err := testutils.CreateGroup(testutils.Group{ - Name: "authz-target-ou2", - Description: "Target Group OU2", - OUID: ts.groupOU2ID, + Name: "authz-target-ou2", + Description: "Target Group OU2", + OUID: ts.groupOU2ID, }) ts.Require().NoError(err, "create target group in OU2") ts.targetGroupOU2ID = targetOU2ID - // ---- 5. Look up the system resource server seeded by bootstrap ---- - systemRSID, err := testutils.GetResourceServerByName("System") - ts.Require().NoError(err, "look up system resource server") + // ---- 5. Create a custom resource server declaring the fine-grained system scopes ---- + // The product ships only the root "system" scope; this reproduces "system:ou:view", + // "system:group" and "system:group:view" so the suite can verify resource-level enforcement + // when configured. + const scopedRSIdentifier = "https://authz-test.example.com/group" + systemRSID, err := testutils.CreateSystemScopedResourceServer( + ts.groupOU1ID, "Authz Test RS (group)", scopedRSIdentifier, "ou", "group") + ts.Require().NoError(err, "create scoped resource server") + ts.scopedRSID = systemRSID // ---- 6. Create a role with system:group permission and assign to the user-manager ---- roleID, err := testutils.CreateRole(testutils.Role{ - Name: groupMgrRoleName, + Name: groupMgrRoleName, OUID: ts.groupOU1ID, Permissions: []testutils.ResourcePermissions{ { @@ -240,6 +247,8 @@ func (ts *GroupAuthzTestSuite) SetupSuite() { groupMgrUsername, groupMgrPassword, true, + "", + scopedRSIdentifier, ) ts.Require().NoError(err, "obtain group-manager token") ts.Require().NotEmpty(tokenResp.AccessToken, "group-manager token must be non-empty") @@ -252,6 +261,11 @@ func (ts *GroupAuthzTestSuite) SetupSuite() { // --------------------------------------------------------------------------- func (ts *GroupAuthzTestSuite) TearDownSuite() { + if ts.scopedRSID != "" { + if err := testutils.DeleteResourceServer(ts.scopedRSID); err != nil { + ts.T().Logf("teardown: delete scoped resource server: %v", err) + } + } if ts.groupMgrRoleID != "" { if err := testutils.DeleteRole(ts.groupMgrRoleID); err != nil { ts.T().Logf("teardown: delete group-manager role: %v", err) @@ -379,9 +393,9 @@ func (ts *GroupAuthzTestSuite) TestGetGroupInOtherOU() { // TestCreateGroupInOwnOU verifies the group-manager can create a group in their own OU. func (ts *GroupAuthzTestSuite) TestCreateGroupInOwnOU() { payload, err := json.Marshal(map[string]interface{}{ - "ouId": ts.groupOU1ID, - "name": "authz-created-group", - "description": "Created Group", + "ouId": ts.groupOU1ID, + "name": "authz-created-group", + "description": "Created Group", }) ts.Require().NoError(err) @@ -403,9 +417,9 @@ func (ts *GroupAuthzTestSuite) TestCreateGroupInOwnOU() { // TestCreateGroupInOtherOU verifies the group-manager is denied creating a group in OU2. func (ts *GroupAuthzTestSuite) TestCreateGroupInOtherOU() { payload, err := json.Marshal(map[string]interface{}{ - "ouId": ts.groupOU2ID, - "name": "authz-denied-group", - "description": "Denied Group", + "ouId": ts.groupOU2ID, + "name": "authz-denied-group", + "description": "Denied Group", }) ts.Require().NoError(err) @@ -419,9 +433,9 @@ func (ts *GroupAuthzTestSuite) TestCreateGroupInOtherOU() { // TestUpdateGroupInOwnOU verifies the group-manager can update a group in their own OU. func (ts *GroupAuthzTestSuite) TestUpdateGroupInOwnOU() { payload, err := json.Marshal(map[string]interface{}{ - "ouId": ts.groupOU1ID, - "name": "authz-target-ou1", - "description": "Updated Description", + "ouId": ts.groupOU1ID, + "name": "authz-target-ou1", + "description": "Updated Description", }) ts.Require().NoError(err) @@ -436,7 +450,7 @@ func (ts *GroupAuthzTestSuite) TestUpdateGroupInOwnOU() { func (ts *GroupAuthzTestSuite) TestUpdateGroupInOtherOU() { payload, err := json.Marshal(map[string]interface{}{ "ouId": ts.groupOU2ID, - "name": "Should Not Update", + "name": "Should Not Update", }) ts.Require().NoError(err) diff --git a/tests/integration/ou/ou_authz_test.go b/tests/integration/ou/ou_authz_test.go index 596da934bb..27e1de1dd9 100644 --- a/tests/integration/ou/ou_authz_test.go +++ b/tests/integration/ou/ou_authz_test.go @@ -28,16 +28,17 @@ import ( "net/url" "testing" - "github.com/thunder-id/thunderid/tests/integration/testutils" "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" ) // OUAuthzTestSuite validates the OU authorization model end-to-end. // -// The in-process bootstrap (backend/cmd/server/bootstrap/01-default-resources.yaml) -// seeds the following hierarchical permission structure under the "system" resource server: +// The product ships only the root "system" scope by default. This suite creates a custom +// resource server that declares the following hierarchical permission structure, simulating an +// operator configuring fine-grained scopes: // -// system RS (name: "System") +// Authz Test RS (ou) // └── Resource "system" → permission "system" // └── Resource "ou" → permission "system:ou" // └── Action "view" → permission "system:ou:view" @@ -67,6 +68,7 @@ type OUAuthzTestSuite struct { // Test-specific role and OU-admin user ouAdminRoleID string ouAdminUserID string + scopedRSID string // HTTP client that carries the OU-admin's view-only access token ouViewClient *http.Client @@ -144,8 +146,8 @@ func (ts *OUAuthzTestSuite) SetupSuite() { // ---- 3. Create the OU-admin user in OU1 ---- userID, err := testutils.CreateUser(testutils.User{ - Type: schema.Name, - OUID: ts.ou1ID, + Type: schema.Name, + OUID: ts.ou1ID, Attributes: json.RawMessage(fmt.Sprintf( `{"username": %q, "password": %q}`, ouAdminUsername, ouAdminPassword, @@ -154,15 +156,19 @@ func (ts *OUAuthzTestSuite) SetupSuite() { ts.Require().NoError(err, "create ou-admin user") ts.ouAdminUserID = userID - // ---- 4. Look up the system resource server that was seeded by bootstrap ---- - // We use the system RS ID to attach the correct permission to the test role. - systemRSID, err := testutils.GetResourceServerByName("System") - ts.Require().NoError(err, "look up system resource server") + // ---- 4. Create a custom resource server declaring the fine-grained system scopes ---- + // The product ships only the root "system" scope; this reproduces the "system:ou:view" + // permission so the suite can verify resource-level enforcement when configured. + const scopedRSIdentifier = "https://authz-test.example.com/ou" + systemRSID, err := testutils.CreateSystemScopedResourceServer( + ts.ou1ID, "Authz Test RS (ou)", scopedRSIdentifier, "ou") + ts.Require().NoError(err, "create scoped resource server") + ts.scopedRSID = systemRSID // ---- 5. Create a role with permission system:ou:view ---- role := testutils.Role{ - Name: ouViewRoleName, - OUID: ts.ou1ID, + Name: ouViewRoleName, + OUID: ts.ou1ID, Permissions: []testutils.ResourcePermissions{ { ResourceServerID: systemRSID, @@ -185,6 +191,8 @@ func (ts *OUAuthzTestSuite) SetupSuite() { ouAdminUsername, ouAdminPassword, true, + "", + scopedRSIdentifier, ) ts.Require().NoError(err, "obtain ou-admin scoped token") ts.Require().NotEmpty(tokenResp.AccessToken, "ou-admin token must be non-empty") @@ -208,6 +216,11 @@ func (ts *OUAuthzTestSuite) TearDownSuite() { ts.T().Logf("teardown: delete ou-admin user: %v", err) } } + if ts.scopedRSID != "" { + if err := testutils.DeleteResourceServer(ts.scopedRSID); err != nil { + ts.T().Logf("teardown: delete scoped resource server: %v", err) + } + } if authzEntityTypeID != "" { if err := testutils.DeleteUserType(authzEntityTypeID); err != nil { ts.T().Logf("teardown: delete user type: %v", err) diff --git a/tests/integration/testutils/api_utils.go b/tests/integration/testutils/api_utils.go index 99e718c831..98a489558e 100644 --- a/tests/integration/testutils/api_utils.go +++ b/tests/integration/testutils/api_utils.go @@ -1455,6 +1455,128 @@ func createAction(resourceServerID string, action Action) (string, error) { return createdAction.ID, nil } +// CreateResource creates a resource under a resource server via API and returns the created +// resource ID. parentID may be empty to create a top-level resource. +func CreateResource(resourceServerID, name, handle, parentID string) (string, error) { + client := GetHTTPClient() + + body := map[string]interface{}{"name": name, "handle": handle} + if parentID != "" { + body["parent"] = parentID + } + payload, err := json.Marshal(body) + if err != nil { + return "", fmt.Errorf("failed to marshal resource: %w", err) + } + + url := fmt.Sprintf("%s/resource-servers/%s/resources", TestServerURL, resourceServerID) + req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("expected status 201, got %d. Response: %s", resp.StatusCode, string(bodyBytes)) + } + + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal(bodyBytes, &created); err != nil { + return "", fmt.Errorf("failed to unmarshal resource response: %w", err) + } + return created.ID, nil +} + +// createActionUnderResource creates an action nested under a resource and returns the action ID. +func createActionUnderResource(resourceServerID, resourceID string, action Action) (string, error) { + client := GetHTTPClient() + + actionJSON, err := json.Marshal(action) + if err != nil { + return "", fmt.Errorf("failed to marshal action: %w", err) + } + + url := fmt.Sprintf("%s/resource-servers/%s/resources/%s/actions", TestServerURL, resourceServerID, resourceID) + req, err := http.NewRequest("POST", url, bytes.NewReader(actionJSON)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("expected status 201, got %d. Response: %s", resp.StatusCode, string(bodyBytes)) + } + + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal(bodyBytes, &created); err != nil { + return "", fmt.Errorf("failed to unmarshal action response: %w", err) + } + return created.ID, nil +} + +// CreateSystemScopedResourceServer creates a custom resource server that reproduces the +// hierarchical "system::view" permission strings used by the built-in system management +// APIs. The product ships only the root "system" scope by default; this helper simulates an +// operator declaring fine-grained scopes, letting the authz suites verify that resource-level +// permissions still enforce when configured. It builds a "system" root resource, then one child +// resource per handle (each with a "view" action), yielding the permissions "system", +// "system:" and "system::view". Returns the resource server ID; delete it with +// DeleteResourceServer during teardown. +func CreateSystemScopedResourceServer(ouID, name, identifier string, childHandles ...string) (string, error) { + rsID, err := createResourceServer(ResourceServer{ + Name: name, + Identifier: identifier, + OUID: ouID, + }) + if err != nil { + return "", fmt.Errorf("failed to create resource server: %w", err) + } + + systemID, err := CreateResource(rsID, "System", "system", "") + if err != nil { + return "", rollbackResourceServer(rsID, fmt.Errorf("failed to create system resource: %w", err)) + } + + for _, handle := range childHandles { + childID, err := CreateResource(rsID, handle, handle, systemID) + if err != nil { + return "", rollbackResourceServer(rsID, fmt.Errorf("failed to create %q resource: %w", handle, err)) + } + if _, err := createActionUnderResource(rsID, childID, Action{Name: "View", Handle: "view"}); err != nil { + return "", rollbackResourceServer(rsID, fmt.Errorf("failed to create view action for %q: %w", handle, err)) + } + } + + return rsID, nil +} + +// rollbackResourceServer deletes a partially built resource server after a setup step failed. It +// returns the original cause, wrapping any cleanup failure so neither error is silently discarded. +func rollbackResourceServer(rsID string, cause error) error { + if delErr := DeleteResourceServer(rsID); delErr != nil { + return fmt.Errorf("%w (resource server cleanup also failed: %v)", cause, delErr) + } + return cause +} + // CreateFlow creates a flow via API and returns the flow ID func CreateFlow(flowDefinition Flow) (string, error) { flowJSON, err := json.Marshal(flowDefinition) diff --git a/tests/integration/testutils/oauth2_utils.go b/tests/integration/testutils/oauth2_utils.go index 881fa98efd..8a9f56b512 100644 --- a/tests/integration/testutils/oauth2_utils.go +++ b/tests/integration/testutils/oauth2_utils.go @@ -467,10 +467,15 @@ func ObtainAccessTokenWithPassword(clientID, redirectURI, scope, username, passw // Bind the token to a resource server via the RFC 8707 resource parameter, mirroring the // console runtime config. The CONSOLE app targets the bootstrapped System resource server. + // optionalParams[1], when non-empty, overrides the resource indicator so a test can bind the + // token to a specific resource server (e.g. one declaring fine-grained system scopes). resource := "" if clientID == "CONSOLE" { resource = SystemResourceIdentifier } + if len(optionalParams) > 1 && optionalParams[1] != "" { + resource = optionalParams[1] + } // Step 1: Initiate authorization flow with PKCE resp, err := InitiateAuthorizationFlowWithPKCE(clientID, redirectURI, "code", scope, "test-state", resource, diff --git a/tests/integration/user/user_authz_test.go b/tests/integration/user/user_authz_test.go index 3082927cc1..afe553a2d1 100644 --- a/tests/integration/user/user_authz_test.go +++ b/tests/integration/user/user_authz_test.go @@ -26,8 +26,8 @@ import ( "net/http" "testing" - "github.com/thunder-id/thunderid/tests/integration/testutils" "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" ) // UserAuthzTestSuite validates that user CRUD operations respect OU-scoped authz. @@ -64,6 +64,7 @@ type UserAuthzTestSuite struct { // Test role and users userMgrRoleID string userMgrUserID string + scopedRSID string targetUserOU1ID string deletableUserOU1ID string targetUserOU2ID string @@ -116,7 +117,7 @@ func (ts *UserAuthzTestSuite) SetupSuite() { // ---- 2. Create user types (one per OU) ---- schemaOU1ID, err := testutils.CreateUserType(testutils.UserType{ - Name: entityTypeOU1Name, + Name: entityTypeOU1Name, OUID: ts.userOU1ID, Schema: map[string]interface{}{ "username": map[string]interface{}{"type": "string"}, @@ -128,7 +129,7 @@ func (ts *UserAuthzTestSuite) SetupSuite() { ts.entityTypeOU1ID = schemaOU1ID schemaOU2ID, err := testutils.CreateUserType(testutils.UserType{ - Name: entityTypeOU2Name, + Name: entityTypeOU2Name, OUID: ts.userOU2ID, Schema: map[string]interface{}{ "display_name": map[string]interface{}{"type": "string"}, @@ -139,7 +140,7 @@ func (ts *UserAuthzTestSuite) SetupSuite() { // ---- 3. Create the user-manager in OU1 (needs username+password for token grant) ---- userMgrID, err := testutils.CreateUser(testutils.User{ - Type: entityTypeOU1Name, + Type: entityTypeOU1Name, OUID: ts.userOU1ID, Attributes: json.RawMessage(fmt.Sprintf( `{"username": %q, "password": %q, "display_name": "User Manager"}`, @@ -151,36 +152,41 @@ func (ts *UserAuthzTestSuite) SetupSuite() { // ---- 4. Create target users ---- targetOU1ID, err := testutils.CreateUser(testutils.User{ - Type: entityTypeOU1Name, - OUID: ts.userOU1ID, - Attributes: json.RawMessage(`{"username": "authz-target-ou1", "display_name": "Target User OU1"}`), + Type: entityTypeOU1Name, + OUID: ts.userOU1ID, + Attributes: json.RawMessage(`{"username": "authz-target-ou1", "display_name": "Target User OU1"}`), }) ts.Require().NoError(err, "create target user in OU1") ts.targetUserOU1ID = targetOU1ID deletableID, err := testutils.CreateUser(testutils.User{ - Type: entityTypeOU1Name, - OUID: ts.userOU1ID, - Attributes: json.RawMessage(`{"username": "authz-deletable-ou1", "display_name": "Deletable User OU1"}`), + Type: entityTypeOU1Name, + OUID: ts.userOU1ID, + Attributes: json.RawMessage(`{"username": "authz-deletable-ou1", "display_name": "Deletable User OU1"}`), }) ts.Require().NoError(err, "create deletable user in OU1") ts.deletableUserOU1ID = deletableID targetOU2ID, err := testutils.CreateUser(testutils.User{ - Type: entityTypeOU2Name, - OUID: ts.userOU2ID, - Attributes: json.RawMessage(`{"display_name": "Target User OU2"}`), + Type: entityTypeOU2Name, + OUID: ts.userOU2ID, + Attributes: json.RawMessage(`{"display_name": "Target User OU2"}`), }) ts.Require().NoError(err, "create target user in OU2") ts.targetUserOU2ID = targetOU2ID - // ---- 5. Look up the system resource server seeded by bootstrap ---- - systemRSID, err := testutils.GetResourceServerByName("System") - ts.Require().NoError(err, "look up system resource server") + // ---- 5. Create a custom resource server declaring the fine-grained system scopes ---- + // The product ships only the root "system" scope; this reproduces "system:user" and + // "system:usertype:view" so the suite can verify resource-level enforcement when configured. + const scopedRSIdentifier = "https://authz-test.example.com/user" + systemRSID, err := testutils.CreateSystemScopedResourceServer( + ts.userOU1ID, "Authz Test RS (user)", scopedRSIdentifier, "user", "usertype") + ts.Require().NoError(err, "create scoped resource server") + ts.scopedRSID = systemRSID // ---- 6. Create a role with system:user permission and assign to the user-manager ---- roleID, err := testutils.CreateRole(testutils.Role{ - Name: userMgrRoleName, + Name: userMgrRoleName, OUID: ts.userOU1ID, Permissions: []testutils.ResourcePermissions{ { @@ -203,6 +209,8 @@ func (ts *UserAuthzTestSuite) SetupSuite() { userMgrUsername, userMgrPassword, true, + "", + scopedRSIdentifier, ) ts.Require().NoError(err, "obtain user-manager token") ts.Require().NotEmpty(tokenResp.AccessToken, "user-manager token must be non-empty") @@ -220,6 +228,11 @@ func (ts *UserAuthzTestSuite) TearDownSuite() { ts.T().Logf("teardown: delete user-manager role: %v", err) } } + if ts.scopedRSID != "" { + if err := testutils.DeleteResourceServer(ts.scopedRSID); err != nil { + ts.T().Logf("teardown: delete scoped resource server: %v", err) + } + } for _, id := range []string{ts.targetUserOU1ID, ts.deletableUserOU1ID, ts.userMgrUserID} { if id != "" { if err := testutils.DeleteUser(id); err != nil { @@ -328,7 +341,7 @@ func (ts *UserAuthzTestSuite) TestGetUserInOtherOU() { func (ts *UserAuthzTestSuite) TestCreateUserInOwnOU() { payload, err := json.Marshal(map[string]interface{}{ "ouId": ts.userOU1ID, - "type": entityTypeOU1Name, + "type": entityTypeOU1Name, "attributes": map[string]interface{}{ "username": "authz-created-user", "display_name": "Created User", @@ -355,7 +368,7 @@ func (ts *UserAuthzTestSuite) TestCreateUserInOwnOU() { func (ts *UserAuthzTestSuite) TestCreateUserInOtherOU() { payload, err := json.Marshal(map[string]interface{}{ "ouId": ts.userOU2ID, - "type": entityTypeOU2Name, + "type": entityTypeOU2Name, "attributes": map[string]interface{}{ "display_name": "Denied User", }, @@ -372,7 +385,7 @@ func (ts *UserAuthzTestSuite) TestCreateUserInOtherOU() { // TestUpdateUserInOwnOU verifies the user-manager can update a user in their own OU. func (ts *UserAuthzTestSuite) TestUpdateUserInOwnOU() { payload, err := json.Marshal(map[string]interface{}{ - "type": entityTypeOU1Name, + "type": entityTypeOU1Name, "ouId": ts.userOU1ID, "attributes": map[string]interface{}{ "username": "authz-target-ou1", @@ -391,7 +404,7 @@ func (ts *UserAuthzTestSuite) TestUpdateUserInOwnOU() { // TestUpdateUserInOtherOU verifies the user-manager is denied updating a user in OU2. func (ts *UserAuthzTestSuite) TestUpdateUserInOtherOU() { payload, err := json.Marshal(map[string]interface{}{ - "type": entityTypeOU2Name, + "type": entityTypeOU2Name, "ouId": ts.userOU2ID, "attributes": map[string]interface{}{ "display_name": "Should Not Update", diff --git a/tests/integration/usertype/usertype_authz_test.go b/tests/integration/usertype/usertype_authz_test.go index a79a2fc928..0b56b85e0b 100644 --- a/tests/integration/usertype/usertype_authz_test.go +++ b/tests/integration/usertype/usertype_authz_test.go @@ -27,16 +27,17 @@ import ( "strings" "testing" - "github.com/thunder-id/thunderid/tests/integration/testutils" "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/tests/integration/testutils" ) // UserTypeAuthzTestSuite validates the user type authorization model end-to-end. // -// The bootstrap script seeds the following hierarchical permission structure -// under the "system" resource server: +// The product ships only the root "system" scope by default. This suite creates a custom +// resource server that declares the following hierarchical permission structure, simulating an +// operator configuring fine-grained scopes: // -// system RS (name: "System") +// Authz Test RS (usertype) // └── Resource "system" → permission "system" // └── Resource "usertype" → permission "system:usertype" // └── Action "view" → permission "system:usertype:view" @@ -73,6 +74,7 @@ type UserTypeAuthzTestSuite struct { // Test-specific role and user schemaAdminRoleID string schemaAdminUserID string + scopedRSID string // Schema created by the scoped user during write tests ou12SchemaID string @@ -167,8 +169,8 @@ func (ts *UserTypeAuthzTestSuite) SetupSuite() { // ---- 3. Create the test user in OU12 (uses OU1's schema via inheritance) ---- userID, err := testutils.CreateUser(testutils.User{ - Type: ou1Schema.Name, - OUID: ts.ou12ID, + Type: ou1Schema.Name, + OUID: ts.ou12ID, Attributes: json.RawMessage(fmt.Sprintf( `{"username": %q, "password": %q}`, schemaAdminUsername, schemaAdminPassword, @@ -177,13 +179,18 @@ func (ts *UserTypeAuthzTestSuite) SetupSuite() { ts.Require().NoError(err, "create schema-admin user in OU12") ts.schemaAdminUserID = userID - // ---- 4. Look up the system resource server seeded by bootstrap ---- - systemRSID, err := testutils.GetResourceServerByName("System") - ts.Require().NoError(err, "look up system resource server") + // ---- 4. Create a custom resource server declaring the fine-grained system scopes ---- + // The product ships only the root "system" scope; this reproduces "system:usertype" so the + // suite can verify resource-level enforcement when configured. + const scopedRSIdentifier = "https://authz-test.example.com/usertype" + systemRSID, err := testutils.CreateSystemScopedResourceServer( + ts.ou12ID, "Authz Test RS (usertype)", scopedRSIdentifier, "usertype") + ts.Require().NoError(err, "create scoped resource server") + ts.scopedRSID = systemRSID // ---- 5. Create a role with system:usertype permission ---- role := testutils.Role{ - Name: schemaAdminRoleName, + Name: schemaAdminRoleName, OUID: ts.ou12ID, Permissions: []testutils.ResourcePermissions{ { @@ -207,6 +214,8 @@ func (ts *UserTypeAuthzTestSuite) SetupSuite() { schemaAdminUsername, schemaAdminPassword, true, + "", + scopedRSIdentifier, ) ts.Require().NoError(err, "obtain schema-admin scoped token") ts.Require().NotEmpty(tokenResp.AccessToken, "schema-admin token must be non-empty") @@ -226,6 +235,11 @@ func (ts *UserTypeAuthzTestSuite) TearDownSuite() { ts.T().Logf("teardown: delete role: %v", err) } } + if ts.scopedRSID != "" { + if err := testutils.DeleteResourceServer(ts.scopedRSID); err != nil { + ts.T().Logf("teardown: delete scoped resource server: %v", err) + } + } // Delete the test user. if ts.schemaAdminUserID != "" { if err := testutils.DeleteUser(ts.schemaAdminUserID); err != nil { @@ -344,9 +358,9 @@ func (ts *UserTypeAuthzTestSuite) TestGetSiblingOUSchema() { // OU1's schema even though they can read it. func (ts *UserTypeAuthzTestSuite) TestUpdateAncestorOUSchema() { payload, err := json.Marshal(UpdateUserTypeRequest{ - Name: "schema-authz-ou1-schema", - OUID: ts.ou1ID, - Schema: json.RawMessage(`{"username": {"type": "string", "unique": true}}`), + Name: "schema-authz-ou1-schema", + OUID: ts.ou1ID, + Schema: json.RawMessage(`{"username": {"type": "string", "unique": true}}`), }) ts.Require().NoError(err) @@ -371,9 +385,9 @@ func (ts *UserTypeAuthzTestSuite) TestDeleteAncestorOUSchema() { // schema in OU2 (outside their hierarchy). func (ts *UserTypeAuthzTestSuite) TestCreateSchemaInSiblingOU() { payload, err := json.Marshal(CreateUserTypeRequest{ - Name: "schema-authz-ou2-blocked", - OUID: ts.ou2ID, - Schema: json.RawMessage(`{"username": {"type": "string", "unique": true}}`), + Name: "schema-authz-ou2-blocked", + OUID: ts.ou2ID, + Schema: json.RawMessage(`{"username": {"type": "string", "unique": true}}`), }) ts.Require().NoError(err) @@ -394,7 +408,7 @@ func (ts *UserTypeAuthzTestSuite) TestCreateSchemaInSiblingOU() { func (ts *UserTypeAuthzTestSuite) TestOwnOUSchemaLifecycle() { // ---- Create ---- createPayload, err := json.Marshal(CreateUserTypeRequest{ - Name: "schema-authz-ou12-schema", + Name: "schema-authz-ou12-schema", OUID: ts.ou12ID, Schema: json.RawMessage(`{ "username": {"type": "string", "unique": true}, @@ -428,7 +442,7 @@ func (ts *UserTypeAuthzTestSuite) TestOwnOUSchemaLifecycle() { // ---- Update ---- updatePayload, err := json.Marshal(UpdateUserTypeRequest{ - Name: "schema-authz-ou12-schema-updated", + Name: "schema-authz-ou12-schema-updated", OUID: ts.ou12ID, Schema: json.RawMessage(`{ "username": {"type": "string", "unique": true},