diff --git a/CLAUDE.md b/CLAUDE.md index abfa3cae..e0c975d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar - Storage: PostgreSQL for RBAC, config, and audit; ClickHouse for all observability data (executions, search, logs, metrics, stats, diagrams). ClickHouse schema migrations in `clickhouse/*.sql`, run idempotently on startup by `ClickHouseSchemaInitializer`. Use `IF NOT EXISTS` for CREATE and ADD PROJECTION. - Logging: ClickHouse JDBC set to INFO (`com.clickhouse`), HTTP client to WARN (`org.apache.hc.client5`) in application.yml - Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration. CORS: `CAMELEER_CORS_ALLOWED_ORIGINS` (comma-separated) overrides `CAMELEER_UI_ORIGIN` for multi-origin setups (e.g., reverse proxy). UI role gating: Admin sidebar/routes hidden for non-ADMIN; diagram toolbar and route control hidden for VIEWER; Config is a main tab (`/config` all apps, `/config/:appId` single app with detail; sidebar clicks stay on config, route clicks resolve to parent app). Read-only for VIEWER, editable for OPERATOR+. Role helpers: `useIsAdmin()`, `useCanControl()` in `auth-store.ts`. Route guard: `RequireAdmin` in `auth/RequireAdmin.tsx`. -- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Configurable `userIdClaim` (default `sub`) determines which id_token claim is used as the user identifier. Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_OIDC_ISSUER_URI` is set. `CAMELEER_OIDC_JWK_SET_URI` overrides JWKS discovery for container networking. `CAMELEER_OIDC_TLS_SKIP_VERIFY=true` disables TLS cert verification for OIDC calls (self-signed CAs). Scope-based role mapping via `SystemRole.normalizeScope()` (case-insensitive, strips `server:` prefix): `admin`/`server:admin` → ADMIN, `operator`/`server:operator` → OPERATOR, `viewer`/`server:viewer` → VIEWER. SSO: when OIDC enabled, UI auto-redirects to provider with `prompt=none` for silent sign-in; falls back to `/login?local` on `login_required`, retries without `prompt=none` on `consent_required`. Logout always redirects to `/login?local` (via OIDC end_session or direct fallback) to prevent SSO re-login loops. Auto-signup provisions new OIDC users with default roles. System roles synced on every OIDC login (revocations propagate on next login); group memberships are never touched. If OIDC returns no roles and user already has local roles, existing roles are preserved. Supports ES384, ES256, RS256. Shared OIDC logic in `OidcProviderHelper` (discovery, JWK source, algorithm set). +- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API/UI, stored in database (`server_config` table). Configurable `userIdClaim` (default `sub`) determines which id_token claim is used as the user identifier. Resource server mode: accepts external access tokens (Logto M2M) via JWKS validation when `CAMELEER_OIDC_ISSUER_URI` is set. `CAMELEER_OIDC_JWK_SET_URI` overrides JWKS discovery for container networking. `CAMELEER_OIDC_TLS_SKIP_VERIFY=true` disables TLS cert verification for OIDC calls (self-signed CAs). Scope-based role mapping via `SystemRole.normalizeScope()` (case-insensitive, strips `server:` prefix): `admin`/`server:admin` → ADMIN, `operator`/`server:operator` → OPERATOR, `viewer`/`server:viewer` → VIEWER. SSO: when OIDC enabled, UI auto-redirects to provider with `prompt=none` for silent sign-in; falls back to `/login?local` on `login_required`, retries without `prompt=none` on `consent_required`. Logout always redirects to `/login?local` (via OIDC end_session or direct fallback) to prevent SSO re-login loops. Auto-signup provisions new OIDC users with default roles. System roles synced on every OIDC login via `syncOidcRoles` — always overwrites directly-assigned roles (falls back to `defaultRoles` when OIDC returns none); uses `getDirectRolesForUser` to avoid touching group-inherited roles. Group memberships are never touched. Supports ES384, ES256, RS256. Shared OIDC logic in `OidcProviderHelper` (discovery, JWK source, algorithm set). - OIDC role extraction: `OidcTokenExchanger` reads roles from the **access_token** first (JWT with `at+jwt` type, decoded by a separate processor), then falls back to id_token. `OidcConfig` includes `audience` (RFC 8707 resource indicator — included in both authorization request and token exchange POST body to trigger JWT access tokens) and `additionalScopes` (extra scopes for the SPA to request). The `rolesClaim` config points to the claim name in the token (e.g., `"roles"` for Custom JWT claims, `"realm_access.roles"` for Keycloak). All provider-specific configuration is external — no provider-specific code in the server. - User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users` - Usage analytics: ClickHouse `usage_events` table tracks authenticated UI requests, flushed every 5s diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java index d304b970..c50cc1b0 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java @@ -239,7 +239,8 @@ public class RbacServiceImpl implements RbacService { return max; } - private List getDirectRolesForUser(String userId) { + @Override + public List getDirectRolesForUser(String userId) { return jdbc.query(""" SELECT r.id, r.name, r.system FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ? diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java index 5f92538a..c7df0bc2 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java @@ -174,18 +174,6 @@ public class OidcAuthController { } private void syncOidcRoles(String userId, List oidcRoles, OidcConfig config) { - // If OIDC returned no roles and user already has local roles, preserve them - if (oidcRoles.isEmpty()) { - Set current = rbacService.getEffectiveRolesForUser(userId).stream() - .filter(r -> SystemRole.isSystem(r.id())) - .map(RoleSummary::id) - .collect(Collectors.toSet()); - if (!current.isEmpty()) { - log.info("syncOidcRoles: userId={}, no OIDC roles, preserving existing roles: {}", userId, current); - return; - } - } - List roleNames = !oidcRoles.isEmpty() ? oidcRoles : config.defaultRoles(); log.info("syncOidcRoles: userId={}, oidcRoles={}, defaultRoles={}, using={}", userId, oidcRoles, config.defaultRoles(), roleNames); @@ -199,8 +187,8 @@ public class OidcAuthController { } } - // Current system roles (excludes group-inherited roles) - Set current = rbacService.getEffectiveRolesForUser(userId).stream() + // Only compare against directly-assigned roles (not group-inherited) + Set current = rbacService.getDirectRolesForUser(userId).stream() .filter(r -> SystemRole.isSystem(r.id())) .map(RoleSummary::id) .collect(Collectors.toSet()); diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java index b01bf564..79faa28b 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java @@ -10,6 +10,7 @@ public interface RbacService { void removeRoleFromUser(String userId, UUID roleId); void addUserToGroup(String userId, UUID groupId); void removeUserFromGroup(String userId, UUID groupId); + List getDirectRolesForUser(String userId); List getEffectiveRolesForUser(String userId); List getEffectiveGroupsForUser(String userId); List getEffectiveRolesForGroup(UUID groupId); diff --git a/docs/SERVER-CAPABILITIES.md b/docs/SERVER-CAPABILITIES.md index 4800e710..866fe06e 100644 --- a/docs/SERVER-CAPABILITIES.md +++ b/docs/SERVER-CAPABILITIES.md @@ -271,11 +271,11 @@ Server derives an Ed25519 keypair deterministically from the JWT secret. Public ### OIDC Integration -Configured via admin API (`/api/v1/admin/oidc`) or admin UI. Supports any OpenID Connect provider. Features: configurable user ID claim (`userIdClaim`, default `sub` — e.g., `email`, `preferred_username`), role claim extraction (supports nested paths like `realm_access.roles`), auto-signup (auto-provisions new users on first OIDC login), configurable display name claim, constant-time token rotation via dual bootstrap tokens, PKCE (S256) on all authorization requests. Supports ES384 (Logto default), ES256, and RS256 for id_token validation. System roles are synced on every OIDC login (not just first) — revoking a scope in the provider takes effect on next login. Group memberships (manually assigned) are never touched by the sync. Role normalization via `SystemRole.normalizeScope()` (case-insensitive, strips `server:` prefix). Shared OIDC infrastructure (discovery, JWK source, algorithm set) centralized in `OidcProviderHelper`. +Configured via admin API (`/api/v1/admin/oidc`) or admin UI. Supports any OpenID Connect provider. Features: configurable user ID claim (`userIdClaim`, default `sub` — e.g., `email`, `preferred_username`), role claim extraction from access_token then id_token (supports nested paths like `realm_access.roles` and space-delimited scope strings), auto-signup (auto-provisions new users on first OIDC login), configurable display name claim, constant-time token rotation via dual bootstrap tokens, RFC 8707 resource indicators (`audience` config). Backend is a confidential client (client_secret authentication, no PKCE). Supports ES384 (Logto default), ES256, and RS256. Directly-assigned system roles are overwritten on every OIDC login (falls back to `defaultRoles` when OIDC returns none); uses `getDirectRolesForUser` so group-inherited roles are never touched. Role normalization via `SystemRole.normalizeScope()` (case-insensitive, strips `server:` prefix). Shared OIDC infrastructure (discovery, JWK source, algorithm set) centralized in `OidcProviderHelper`. ### SSO Auto-Redirect -When OIDC is configured and enabled, the login page automatically redirects to the OIDC provider with `prompt=none` and PKCE (S256) for silent SSO. If the user has an active provider session, they are signed in without seeing a login form. If `consent_required` is returned (first login, scopes not yet granted), the flow retries without `prompt=none` so the user can grant consent once. If `login_required` (no provider session), falls back to the login form. Bypass auto-redirect with `/login?local`. Logout always redirects to `/login?local` — either via the OIDC `end_session_endpoint` (with `post_logout_redirect_uri`) or as a direct fallback — preventing SSO re-login loops. +When OIDC is configured and enabled, the login page automatically redirects to the OIDC provider with `prompt=none` for silent SSO. If the user has an active provider session, they are signed in without seeing a login form. If `consent_required` is returned (first login, scopes not yet granted), the flow retries without `prompt=none` so the user can grant consent once. If `login_required` (no provider session), falls back to the login form. Bypass auto-redirect with `/login?local`. Logout always redirects to `/login?local` — either via the OIDC `end_session_endpoint` (with `post_logout_redirect_uri`) or as a direct fallback — preventing SSO re-login loops. ### OIDC Resource Server