diff --git a/docs/prds/prd-settings-permissions-refactor.md b/docs/prds/prd-settings-permissions-refactor.md new file mode 100644 index 00000000..738ca6fc --- /dev/null +++ b/docs/prds/prd-settings-permissions-refactor.md @@ -0,0 +1,153 @@ +# PRD: Settings Permissions Refactor + Grouped Permissions UI + +## Problem Statement + +The Settings area of the application currently has a single, coarse permission named `settings` with only two access levels: `None` and `Admin`. This forces an all-or-nothing model where any user who needs to manage a single Settings sub-page (say, Business Roles or Tags) must be granted full Admin over every other Settings sub-page — including sensitive ones like Git integration, MCP tokens, App Roles, and Connectors. + +In practice, this means: + +- **Administrators** cannot delegate operational ownership. A Data Steward who should curate the Business Glossary and Business Roles is either locked out of `/settings` entirely or handed the keys to every infrastructure setting. +- **Operators** of specific surfaces (Platform Engineers, Security Officers, Glossary Owners) have no role between "no Settings access" and "full system Admin". +- **The role configuration UI** lists every feature permission in one flat scroll-list. With 30+ features, it is hard for an Admin to find a particular permission, hard to reason about which permissions belong together, and hard to onboard a new persona. + +Additionally, every Settings sub-page (`/settings/general`, `/settings/git`, `/settings/jobs`, etc.) is currently gated solely by the single `settings` permission via `SettingsPageWrapper`. There is no mechanism to express "this role can manage X but not Y inside Settings". + +## Solution + +Replace the single coarse `settings` permission with a layered model: + +1. The `settings` permission becomes a **layout gate** with the full four-level scale (`None` / `Read-only` / `Read/Write` / `Admin`). Holding at least `Read-only` allows a user to open `/settings` and see the Settings sidebar shell. +2. Every Settings sub-page gets its own dedicated permission with the full four-level scale (e.g., `settings-business-roles`, `settings-git`, `settings-jobs`, `settings-mcp`, ...). The Settings sidebar dynamically filters items based on which sub-permissions the user holds. Each sub-page enforces its own permission on both its UI actions and its backend API endpoints. +3. The Role configuration dialog groups every permission in the system by the same logical groups used in the main sidebar — **Discover**, **Build**, **Govern**, **Deploy**, plus a new **Settings** group and an **Other** bucket for cross-cutting permissions. Each group renders under a styled section header so Admins can scan and configure permissions feature-area by feature-area. + +The result is a permission model that supports fine-grained delegated administration of Settings, plus a role-configuration UI that scales with the number of features. + +## User Stories + +### Administrators configuring roles + +1. As an **Admin**, I want to grant a role permission to manage Business Roles without giving that role any access to Git integration or App Roles, so that I can safely delegate glossary curation to a Data Steward. +2. As an **Admin**, I want to see all feature permissions grouped under headings that match the main app sidebar (Discover, Build, Govern, Deploy, Settings, Other), so that I can find the permission I'm looking for without scanning a flat list. +3. As an **Admin**, I want a dedicated "Settings" group inside the role permissions UI that lists every Settings sub-page permission together, so that I can build delegated-administration personas in one place. +4. As an **Admin**, I want each Settings sub-page permission to support the same four levels (None, Read-only, Read/Write, Admin) as other features, so that I can express "view only", "edit but don't delete", and "full control including destructive actions" consistently. +5. As an **Admin**, I want my Admin role to keep working after the upgrade without my having to re-grant any settings sub-permissions, so that the change is invisible from my perspective. +6. As an **Admin**, I want existing non-Admin roles that today have `settings: None` to remain at `None` for every new sub-permission, so that no one accidentally gains new access during the upgrade. +7. As an **Admin**, I want the Role dialog to remember which group sections I've collapsed, so that I can focus on the area I'm editing without losing my place. +8. As an **Admin**, I want the Settings sub-permissions inside the Settings group to be ordered the same way as the Settings sidebar (Reference Data → Configuration → Integrations → Operations → Access Control), so that the matrix mirrors the user-facing navigation. + +### Delegated personas operating Settings + +9. As a **Data Steward**, I want to manage Business Roles, Delivery Methods, Asset Types, Teams, Projects, Tags, and Certification Levels from `/settings/*` without holding Admin, so that I can run governance reference data without needing infrastructure access. +10. As a **Platform Engineer**, I want to manage Git integration, Jobs, Connectors, and Workflows from `/settings/*` without holding Admin, so that I can configure operational plumbing without owning glossary or role data. +11. As a **Security Officer**, I want exclusive Admin access to App Roles, MCP tokens, and the Audit Trail without holding settings rights elsewhere, so that least-privilege separation of duties is enforced. +12. As a **Search Configuration Owner**, I want to manage `/settings/search` and `/settings/semantic-models` (RDF Sources) without any other Settings access, so that I can tune search behavior without touching governance data. +13. As a **non-Admin user with limited Settings access**, I want to see only the Settings sidebar items I am permitted to use, so that I am not confused by clickable pages that immediately deny me. +14. As any **user without `settings >= Read-only`**, I want `/settings` to redirect or show a clean access-denied state, so that the existence of Settings does not create a dead-end in the navigation. +15. As a user **without permission for a specific sub-page**, I want a clear access-denied message if I land on it via a direct URL or bookmark, with a link back home and an indication to request a role. + +### Backend / API consumers + +16. As an **API caller**, I want backend Settings endpoints to enforce the same `settings-` permissions that the UI enforces, so that a user cannot bypass UI gating by calling the API directly. +17. As an **API caller**, I want endpoints that back Reference-Data sub-pages (Domains, Teams, Projects, etc.) to still be callable by users who hold the underlying feature permission for non-Settings consumption (e.g., a domain picker dropdown), so that read access from outside Settings is not regressed. +18. As an **integration developer**, I want `/api/settings/features` to include a `group` field for every feature, so that any external tool that introspects the permission model can render it the same way as the in-app Role dialog. + +### Existing flows that must continue to work + +19. As a current **Admin user**, I want to log in after the upgrade and continue to access every Settings sub-page with full edit rights, so that the upgrade is non-breaking. +20. As an **end user** of features that have a Settings counterpart (e.g., browsing a Domain picker), I want my access to the underlying feature to be unchanged — the new `settings-*` permissions only affect the Settings management UI, not feature consumption. +21. As an **Admin who has customized the built-in roles**, I want the post-upgrade Admin role to receive `Admin` on every new `settings-*` permission automatically (idempotent on startup), so that I do not have to edit it. +22. As an **Admin**, I want existing non-Admin roles to remain unchanged on upgrade (every new `settings-*` permission starts at `None` for them), so that I retain explicit control over who I delegate to. + +### Visibility and discoverability + +23. As any **user**, I want the main app sidebar to show or hide the link to `/settings` based on my `settings` permission, so that the navigation reflects what I can actually do. +24. As a **user requesting a role**, I want to see roles that grant the specific Settings sub-page access I need (e.g., "Tag Curator"), so that I can request the right level of delegation. +25. As an **Admin browsing existing roles**, I want the per-role permissions display in the roles list to also be grouped by the new groups, so that role definitions read consistently with the editor. + +## Implementation Decisions + +### Permission model + +- Introduce a `group` attribute on every feature in the central feature catalog. Valid values: `Discover`, `Build`, `Govern`, `Deploy`, `Settings`, `Other`. +- Promote the `settings` permission from a two-level (`None`/`Admin`) feature to the standard four-level scale (`None`/`Read-only`/`Read/Write`/`Admin`). It functions purely as a layout gate. +- Add one dedicated `settings-` permission per Settings sub-page, each at the four-level scale. The clean-cut approach is used: no Settings sub-page reuses an existing top-level feature ID. The new IDs are: + - Reference Data: `settings-data-domains`, `settings-business-roles`, `settings-delivery-methods`, `settings-asset-types`, `settings-teams`, `settings-projects`, `settings-certification-levels` + - Configuration: `settings-general`, `settings-ui`, `settings-tags`, `settings-connectors` + - Integrations: `settings-git`, `settings-mcp`, `settings-semantic-models`, `settings-search` + - Operations: `settings-jobs`, `settings-delivery`, `settings-workflows` + - Access Control: `settings-roles`, `settings-audit` +- Existing top-level feature IDs that share a name with a Settings sub-page (`data-domains`, `teams`, `projects`, `business-roles`, `delivery-methods`, `tags`, `semantic-models`, `jobs`, `audit`) remain in place and govern *consumption* of the underlying feature elsewhere in the app. They are assigned to the `Other` group. + +### Backend modules to modify + +- **Feature catalog module** (`common/features`): adds the `group` field and the new permission IDs; updates the `settings` allowed-levels list. +- **Settings manager**: extends the response of the features-with-access-levels endpoint to include the `group` field. +- **Settings routes**: routes that previously called `PermissionChecker('settings', ...)` switch to the appropriate `settings-` ID. This covers settings, workflows, certification levels, MCP tokens, connections, and approvals routes. +- **Reference-Data routes** (domains, teams, projects, business roles, delivery methods, asset types, tags, jobs, audit, semantic models): write endpoints get an additional composite dependency that requires the corresponding `settings-` permission alongside the existing underlying permission, so the Settings-page gate is also enforced at the API layer. Read endpoints used outside Settings keep only the underlying check. +- **Role seeder / startup task**: an idempotent step that ensures the built-in Admin role is granted `Admin` on every new `settings-*` permission ID. Other roles are left untouched. + +### Frontend modules to modify + +- **Feature config type** (`types/settings`): extends `FeatureConfig` with an optional `group` field. +- **`SettingsPageWrapper`**: gains a required `permissionId` prop and gates rendering on both the layout permission (`settings >= Read-only`) and the page permission (`permissionId >= Read-only`). +- **Each settings view file** (`views/settings-*.tsx`): passes its dedicated `settings-` permissionId to the wrapper. +- **`SettingsLayout` sidebar component**: each nav item now carries a `permissionId`; items are filtered by `hasPermission(permissionId, Read-only)`; empty groups are hidden; the whole layout redirects if `settings < Read-only`. +- **Route table** (`app.tsx`): the Reference-Data sub-pages (data-domains, teams, projects, business-roles, delivery-methods, asset-types, audit-trail) that render standalone views under `/settings/*` are wrapped in `SettingsPageWrapper` at the route element layer. +- **Per-sub-page setting components**: their `hasPermission('settings', ...)` calls are switched to the dedicated `settings-` IDs at the appropriate level (Read/Write for edits, Admin for destructive actions). +- **Role configuration dialog**: replaces the flat feature-permissions list with a grouped rendering. Groups are ordered Discover, Build, Govern, Deploy, Settings, Other. Items in the Settings group follow the order of the Settings sidebar; items in other groups are sorted alphabetically by display name. +- **i18n locale files**: add display name and short description keys for each new `settings-*` permission, plus group header labels for the Role dialog, across all supported locales (de, en, es, fr, it, ja, nl). + +### API contract changes + +- `GET /api/settings/features` returns `group` for every feature, in addition to `name` and `allowed_levels`. +- No new endpoints. No changes to role storage format — role JSON simply contains entries for the new permission IDs once a role is saved. +- All existing endpoints continue to accept the same payloads; only the `PermissionChecker` IDs change internally. + +### Migration / rollout + +- No database migration required; permissions are config-driven and stored as JSON on each role. +- On application startup, the role seeder ensures the built-in **Admin** role is granted `Admin` on every new `settings-*` permission ID. Idempotent. +- All other existing roles inherit `None` for every new `settings-*` permission on their next read. Admins must explicitly grant new delegated access to non-Admin roles. + +### Interaction details + +- A user with `settings: Read-only` and `settings-git: None` can open `/settings`, see the sidebar, but the Git item is hidden and direct navigation to `/settings/git` shows access denied. +- A user with `settings: None` cannot open `/settings` at all, regardless of what `settings-*` permissions they hold (defense in depth). +- The Settings group header in the Role dialog is rendered with the same uppercase/tracking style as the Settings sidebar group headings, for visual continuity. + +## Testing Decisions + +### What makes a good test + +Tests should exercise externally observable behavior — API responses, rendered UI based on a given permission set, gate decisions — without coupling to internal implementation details like specific function names or component internals. Permission-related tests should drive observable outcomes: HTTP status codes for backend, presence/absence of UI elements and access-denied states for frontend. + +### Modules to be tested + +- **Backend feature catalog**: a unit test asserts that `get_features_with_access_levels()` returns a `group` field for every feature, that `settings` has all four access levels, and that each new `settings-*` permission ID is present with the four-level scale. +- **Backend route gating**: an integration test per representative settings sub-page (general, git, jobs, roles, business-roles) verifies that calls succeed/fail correctly under each combination of `settings` and `settings-` permissions. Existing tests for routes that switched permission IDs are updated. +- **Backend role seeder**: a test asserts that after running the seeder on a fresh DB and on a DB with a pre-existing Admin role, the Admin role ends up with `Admin` on every new `settings-*` permission ID, and that non-Admin roles are not mutated. +- **Frontend permissions store**: a unit test verifies that the `hasPermission` helper correctly distinguishes `settings` from `settings-` and respects each independently, including the four-level ordering. +- **Frontend SettingsLayout sidebar filtering**: a component test renders the layout under different permission sets and asserts which sidebar items are visible, including hiding empty groups. +- **Frontend Role dialog grouping**: a component test renders the dialog with a mocked features response and asserts that features are grouped under the correct headings in the correct order (Discover, Build, Govern, Deploy, Settings, Other) and that Settings items follow the sidebar order. + +### Prior art in the codebase + +- Backend permission integration tests follow the patterns already established in `tests/unit/test_settings_manager.py` for permission-related assertions. +- Frontend permission gating tests follow the pattern in `stores/permissions-store.test.ts`. +- Component-level snapshot or rendering tests follow patterns used for other Shadcn-based dialogs in the project. + +## Out of Scope + +- Splitting any non-Settings feature into finer-grained sub-permissions. This PRD only touches Settings; other features keep their existing single-level permissions. +- Per-row or per-domain access (data-domain-scoped permissions, ABAC, OpenFGA evaluation). The model remains role-based with feature-level granularity. +- A UI for end users to discover what permissions they hold or what they would need to perform an action — only the existing Role configuration UI is in scope. +- Changing the storage format of roles or the `/api/user/permissions` response envelope. Both continue to be feature-id → level dictionaries. +- Adding new high-level feature areas to the sidebar or renaming existing groups (Discover/Build/Govern/Deploy). +- Permission management for the About page, Home page, and other public/static pages. + +## Further Notes + +- The clean-cut decision (every Settings sub-page gets a dedicated permission ID rather than reusing the underlying feature ID) was made because it cleanly separates "managing X via the Settings UI" from "consuming X elsewhere in the app". This avoids ambiguity around what permission a generic `data-domains: Read/Write` grant is intended to authorize. +- The composite check pattern on Reference-Data write routes (require both the underlying permission and the `settings-` permission) means a user can still read domain data via API for the domain picker even if they have no Settings access; but mutating domains via API still requires the Settings delegation. This matches the principle that the Settings UI gate also closes at the API layer. +- The `Other` group serves as a holding pen for cross-cutting permissions that don't belong to a sidebar area (notifications, comments, self-service, ontology, entity relationships, etc.) plus the legacy top-level IDs that overlap with Settings sub-pages. Future PRDs may decide to relocate some of these as the application grows. +- The Role dialog grouping is the visible payoff of this PRD for Admins. The new permission IDs will likely number ~50+ once Settings sub-permissions are added; without grouping, the dialog would become significantly harder to use. diff --git a/src/backend/src/common/features.py b/src/backend/src/common/features.py index 4862e516..013f9503 100644 --- a/src/backend/src/common/features.py +++ b/src/backend/src/common/features.py @@ -39,170 +39,338 @@ class FeatureAccessLevel(str, Enum): ] +# Permission group buckets — mirror the sidebar groups plus a Settings bucket +# and a catch-all `Other` bucket for cross-cutting permissions. +GROUP_DISCOVER = "Discover" +GROUP_BUILD = "Build" +GROUP_GOVERN = "Govern" +GROUP_DEPLOY = "Deploy" +GROUP_SETTINGS = "Settings" +GROUP_OTHER = "Other" + + # Mirroring src/config/features.ts (simplified for now) -# Key: Feature ID, Value: Dict with 'name' and 'allowed_levels' +# Key: Feature ID, Value: Dict with 'name', 'allowed_levels', and 'group' APP_FEATURES: Dict[str, Dict[str, str | List[FeatureAccessLevel]]] = { - # Data Management - 'data-domains': { - 'name': 'Data Domains', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS # Standard CRUD + Admin delete + # --- Discover --- + 'data-catalog': { + 'name': 'Data Catalog', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, # Browse catalog, view lineage + 'group': GROUP_DISCOVER, + }, + 'llm-search': { + 'name': 'LLM Search', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_DISCOVER, }, + + # --- Build --- 'data-products': { 'name': 'Data Products', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + [FeatureAccessLevel.FILTERED] # Allow filtering + 'allowed_levels': READ_WRITE_ADMIN_LEVELS + [FeatureAccessLevel.FILTERED], # Allow filtering + 'group': GROUP_BUILD, }, 'data-contracts': { 'name': 'Data Contracts', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_BUILD, }, - 'teams': { - 'name': 'Teams', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + 'assets': { + 'name': 'Assets', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_BUILD, }, - 'projects': { - 'name': 'Projects', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + 'semantic-models': { + 'name': 'Concept Browser', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_BUILD, + }, + 'schema-importer': { + 'name': 'Schema Importer', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_BUILD, }, + + # --- Govern --- 'compliance': { 'name': 'Compliance', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS - }, - 'process-workflows': { - 'name': 'Process Workflows', - 'allowed_levels': [ - FeatureAccessLevel.NONE, - FeatureAccessLevel.READ_ONLY, - FeatureAccessLevel.ADMIN, # Only Admin gets full write access - ] - }, - 'estate-manager': { - 'name': 'Estate Manager', - 'allowed_levels': READ_ONLY_FULL_LEVELS # Now includes ADMIN + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_GOVERN, }, 'master-data': { 'name': 'Master Data Management', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS # Requires admin for setup? + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_GOVERN, + }, + 'data-asset-reviews': { + 'name': 'Asset Reviews', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, # Stewards review, admins manage + 'group': GROUP_GOVERN, }, - # Security 'security-features': { 'name': 'Security Features', - 'allowed_levels': ADMIN_ONLY_LEVELS # Likely admin only + 'allowed_levels': ADMIN_ONLY_LEVELS, # Likely admin only + 'group': GROUP_GOVERN, }, 'entitlements': { 'name': 'Entitlements', - 'allowed_levels': ADMIN_ONLY_LEVELS # Admin manages personas/groups + 'allowed_levels': ADMIN_ONLY_LEVELS, # Admin manages personas/groups + 'group': GROUP_GOVERN, }, 'entitlements-sync': { 'name': 'Entitlements Sync', - 'allowed_levels': ADMIN_ONLY_LEVELS # Admin manages sync jobs - }, - 'access-grants': { - 'name': 'Access Grants', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS # Users request, admins grant - }, - 'data-asset-reviews': { - 'name': 'Asset Reviews', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS # Stewards review, admins manage - }, - 'data-catalog': { - 'name': 'Data Catalog', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS # Browse catalog, view lineage + 'allowed_levels': ADMIN_ONLY_LEVELS, # Admin manages sync jobs + 'group': GROUP_GOVERN, }, - # Observability / Logs - 'audit': { - 'name': 'Audit & Change Logs', - # Allow read-only, full (if used), and admin; write requires at least READ_WRITE but routes use explicit checks + 'process-workflows': { + 'name': 'Process Workflows', 'allowed_levels': [ FeatureAccessLevel.NONE, FeatureAccessLevel.READ_ONLY, - FeatureAccessLevel.READ_WRITE, - FeatureAccessLevel.FULL, - FeatureAccessLevel.ADMIN, - ] + FeatureAccessLevel.ADMIN, # Only Admin gets full write access + ], + 'group': GROUP_GOVERN, + }, + 'access-grants': { + 'name': 'Access Grants', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, # Users request, admins grant + 'group': GROUP_GOVERN, + }, + + # --- Deploy --- + 'estate-manager': { + 'name': 'Estate Manager', + 'allowed_levels': READ_ONLY_FULL_LEVELS, # Now includes ADMIN + 'group': GROUP_DEPLOY, }, - # Tools 'catalog-commander': { 'name': 'Catalog Commander', - 'allowed_levels': [FeatureAccessLevel.NONE, FeatureAccessLevel.READ_ONLY, FeatureAccessLevel.FULL, FeatureAccessLevel.ADMIN] + 'allowed_levels': [FeatureAccessLevel.NONE, FeatureAccessLevel.READ_ONLY, FeatureAccessLevel.FULL, FeatureAccessLevel.ADMIN], + 'group': GROUP_DEPLOY, }, - # System (Settings is special, About is always visible) + + # --- Settings (layout gate) --- 'settings': { 'name': 'Settings', - 'allowed_levels': ADMIN_ONLY_LEVELS # Only Admins change settings + # Bumped from ADMIN_ONLY to full scale — acts as the layout gate. + # Each sub-page below has its own settings- permission. + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, }, - 'semantic-models': { - 'name': 'Concept Browser', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + + # --- Settings: Reference Data sub-pages --- + 'settings-data-domains': { + 'name': 'Settings — Data Domains', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-business-roles': { + 'name': 'Settings — Business Roles', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-delivery-methods': { + 'name': 'Settings — Delivery Methods', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-asset-types': { + 'name': 'Settings — Asset Types', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-teams': { + 'name': 'Settings — Teams', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-projects': { + 'name': 'Settings — Projects', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-certification-levels': { + 'name': 'Settings — Certification Levels', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, }, - 'tags': { - 'name': 'Tags', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS # READ_WRITE can create tags, ADMIN can manage taxonomy + + # --- Settings: Configuration sub-pages --- + 'settings-general': { + 'name': 'Settings — General', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-ui': { + 'name': 'Settings — UI Customization', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-tags': { + 'name': 'Settings — Tags', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-connectors': { + 'name': 'Settings — Connectors', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, }, - # Search & Discovery - 'llm-search': { - 'name': 'LLM Search', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS # Anyone with read access can use LLM search + + # --- Settings: Integrations sub-pages --- + 'settings-git': { + 'name': 'Settings — Git', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-mcp': { + 'name': 'Settings — MCP', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-semantic-models': { + 'name': 'Settings — RDF Sources', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-search': { + 'name': 'Settings — Search', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, }, - 'notifications': { - 'name': 'Notifications', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS # Users can view/manage own notifications + + # --- Settings: Operations sub-pages --- + 'settings-jobs': { + 'name': 'Settings — Jobs', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-delivery': { + 'name': 'Settings — Delivery', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, + }, + 'settings-workflows': { + 'name': 'Settings — Workflows', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, }, - 'jobs': { - 'name': 'Jobs', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS # View/manage background jobs + + # --- Settings: Access Control sub-pages --- + 'settings-roles': { + 'name': 'Settings — App Roles', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, }, - 'self-service': { - 'name': 'Self Service', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS # Self-service data requests + 'settings-audit': { + 'name': 'Settings — Audit Trail', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_SETTINGS, }, - # Comments & Ratings - cross-cutting feature for social interaction - 'comments': { - 'name': 'Comments & Ratings', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS # READ_WRITE to add, ADMIN to manage all + + # --- Other (cross-cutting / consumption-side permissions) --- + # These existing top-level IDs remain in place to govern consumption of + # the underlying feature elsewhere in the app (pickers, lineage, search + # results, etc.). Their settings-page counterparts live above. + 'data-domains': { + 'name': 'Data Domains', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_OTHER, }, - # Reference Data - 'assets': { - 'name': 'Assets', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + 'teams': { + 'name': 'Teams', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_OTHER, + }, + 'projects': { + 'name': 'Projects', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_OTHER, }, 'business-roles': { 'name': 'Business Roles', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_OTHER, }, 'business-owners': { 'name': 'Business Owners', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_OTHER, }, 'delivery-methods': { 'name': 'Delivery Methods', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_OTHER, + }, + 'tags': { + 'name': 'Tags', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, # READ_WRITE can create tags, ADMIN can manage taxonomy + 'group': GROUP_OTHER, + }, + 'jobs': { + 'name': 'Jobs', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, # View/manage background jobs + 'group': GROUP_OTHER, + }, + 'audit': { + 'name': 'Audit & Change Logs', + # Allow read-only, full (if used), and admin; write requires at least READ_WRITE but routes use explicit checks + 'allowed_levels': [ + FeatureAccessLevel.NONE, + FeatureAccessLevel.READ_ONLY, + FeatureAccessLevel.READ_WRITE, + FeatureAccessLevel.FULL, + FeatureAccessLevel.ADMIN, + ], + 'group': GROUP_OTHER, + }, + 'notifications': { + 'name': 'Notifications', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, # Users can view/manage own notifications + 'group': GROUP_OTHER, + }, + 'self-service': { + 'name': 'Self Service', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, # Self-service data requests + 'group': GROUP_OTHER, + }, + 'comments': { + 'name': 'Comments & Ratings', + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, # READ_WRITE to add, ADMIN to manage all + 'group': GROUP_OTHER, }, - # Ontology-driven model 'ontology': { 'name': 'Ontology Schema', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_OTHER, }, 'entity_relationships': { 'name': 'Entity Relationships', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_OTHER, }, 'entity_subscriptions': { 'name': 'Entity Subscriptions', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS - }, - # Schema Import - 'schema-importer': { - 'name': 'Schema Importer', - 'allowed_levels': READ_WRITE_ADMIN_LEVELS + 'allowed_levels': READ_WRITE_ADMIN_LEVELS, + 'group': GROUP_OTHER, }, # 'about': { ... } # About page doesn't need explicit permissions here - } + +# IDs that are part of the Settings group — used by the role seeder to +# auto-grant ADMIN on the built-in Admin role. +SETTINGS_SUBPAGE_FEATURE_IDS: List[str] = [ + feature_id + for feature_id, config in APP_FEATURES.items() + if feature_id.startswith('settings-') +] + + def get_feature_config() -> Dict[str, Dict[str, str | List[FeatureAccessLevel]]]: """Returns the application feature configuration.""" return APP_FEATURES def get_all_access_levels() -> List[FeatureAccessLevel]: """Returns all possible access levels.""" - return ALL_ACCESS_LEVELS \ No newline at end of file + return ALL_ACCESS_LEVELS diff --git a/src/backend/src/controller/settings_manager.py b/src/backend/src/controller/settings_manager.py index d8164301..0afae67d 100644 --- a/src/backend/src/controller/settings_manager.py +++ b/src/backend/src/controller/settings_manager.py @@ -688,6 +688,74 @@ def ensure_default_roles_exist(self): logger.error(f"Unexpected error during default role check/creation: {e}", exc_info=True) raise # Re-raise other unexpected errors + def upgrade_admin_role_for_new_features(self): + """Idempotent migration: ensure the built-in Admin role has ADMIN on every feature. + + Runs on startup. When new feature IDs are added to ``APP_FEATURES`` (e.g., + the ``settings-*`` sub-page permissions introduced by the Settings + permissions refactor), the existing Admin role in the database will be + missing entries for them. This method backfills those entries with + ``FeatureAccessLevel.ADMIN`` so administrators don't need to manually + re-grant access after every release. + + Only the built-in Admin role (``is_admin=True``) is touched. Non-admin + roles are left untouched so their explicit grants are preserved — admins + must explicitly delegate the new sub-permissions. + """ + try: + all_features_config = get_feature_config() + roles_db = self.app_role_repo.get_all_roles(db=self._db) + updated_count = 0 + + for role_db in roles_db: + if not getattr(role_db, 'is_admin', False): + continue + try: + existing_perms_raw = json.loads(getattr(role_db, 'feature_permissions', '{}') or '{}') + except Exception: + logger.warning( + "Admin role '%s' has unparseable feature_permissions; resetting to ADMIN on all features.", + role_db.name, + ) + existing_perms_raw = {} + + existing_perms: Dict[str, str] = { + k: v for k, v in existing_perms_raw.items() if isinstance(k, str) and isinstance(v, str) + } + missing_ids = [ + feat_id for feat_id in all_features_config.keys() + if feat_id not in existing_perms + ] + if not missing_ids: + continue + + for feat_id in missing_ids: + allowed_levels = all_features_config[feat_id].get('allowed_levels', []) + target = FeatureAccessLevel.ADMIN if FeatureAccessLevel.ADMIN in allowed_levels else ( + allowed_levels[-1] if allowed_levels else FeatureAccessLevel.NONE + ) + existing_perms[feat_id] = target.value + + role_db.feature_permissions = json.dumps(existing_perms) + self._db.add(role_db) + updated_count += 1 + logger.info( + "Backfilled Admin role '%s' with ADMIN on %d new feature ids: %s", + role_db.name, len(missing_ids), missing_ids, + ) + + if updated_count > 0: + self._db.flush() + logger.info("Upgraded %d Admin role(s) with new feature permissions.", updated_count) + else: + logger.info("No Admin roles needed upgrade for new feature permissions.") + + except SQLAlchemyError as e: + logger.error(f"Database error during Admin role upgrade: {e}", exc_info=True) + self._db.rollback() + except Exception as e: + logger.error(f"Unexpected error during Admin role upgrade: {e}", exc_info=True) + def _setup_default_role_hierarchy(self): """Sets up the default role request/approval hierarchy. @@ -1620,14 +1688,21 @@ def _map_db_to_api(self, role_db: AppRoleDb) -> AppRole: ) def get_features_with_access_levels(self) -> Dict[str, Dict[str, str | List[str]]]: - """Returns a dictionary of features and their allowed access levels.""" + """Returns a dictionary of features and their allowed access levels. + + Each entry contains: + - name: human-readable label + - allowed_levels: list of allowed FeatureAccessLevel values (as strings) + - group: one of Discover/Build/Govern/Deploy/Settings/Other for UI grouping + """ features_config = get_feature_config() all_levels = get_all_access_levels() # Convert enum members to their string values for API response return { feature_id: { 'name': config['name'], - 'allowed_levels': [level.value for level in config['allowed_levels']] + 'allowed_levels': [level.value for level in config['allowed_levels']], + 'group': config.get('group', 'Other'), } for feature_id, config in features_config.items() } diff --git a/src/backend/src/routes/approvals_routes.py b/src/backend/src/routes/approvals_routes.py index 4519a7bb..b1f23839 100644 --- a/src/backend/src/routes/approvals_routes.py +++ b/src/backend/src/routes/approvals_routes.py @@ -247,7 +247,7 @@ async def list_agreements( entity_id: Optional[str] = Query(None), limit: int = Query(50, ge=1, le=100), wizard_manager: AgreementWizardManager = Depends(get_agreement_wizard_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ): """List agreements, optionally filtered by entity type/id.""" return wizard_manager.list_agreements( @@ -259,7 +259,7 @@ async def list_agreements( async def get_agreement( agreement_id: str, wizard_manager: AgreementWizardManager = Depends(get_agreement_wizard_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ): """Get a single agreement by ID.""" result = wizard_manager.get_agreement(agreement_id) @@ -272,7 +272,7 @@ async def get_agreement( async def download_agreement_pdf( agreement_id: str, wizard_manager: AgreementWizardManager = Depends(get_agreement_wizard_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ): """Download the agreement as a PDF document. diff --git a/src/backend/src/routes/certification_levels_routes.py b/src/backend/src/routes/certification_levels_routes.py index bf1753af..155628cb 100644 --- a/src/backend/src/routes/certification_levels_routes.py +++ b/src/backend/src/routes/certification_levels_routes.py @@ -21,7 +21,7 @@ router = APIRouter(prefix="/api", tags=["Certification Levels"]) -SETTINGS_FEATURE_ID = "settings" +SETTINGS_FEATURE_ID = "settings-certification-levels" @router.get( diff --git a/src/backend/src/routes/connection_routes.py b/src/backend/src/routes/connection_routes.py index 208151ba..c72c2ebf 100644 --- a/src/backend/src/routes/connection_routes.py +++ b/src/backend/src/routes/connection_routes.py @@ -24,7 +24,7 @@ router = APIRouter(prefix="/api", tags=["Connections"]) -FEATURE_ID = "settings" +FEATURE_ID = "settings-connectors" def _get_manager(db: Session = Depends(get_db)) -> ConnectionsManager: diff --git a/src/backend/src/routes/mcp_tokens_routes.py b/src/backend/src/routes/mcp_tokens_routes.py index 0b3473cc..c2b76b69 100644 --- a/src/backend/src/routes/mcp_tokens_routes.py +++ b/src/backend/src/routes/mcp_tokens_routes.py @@ -33,7 +33,7 @@ def register_routes(app): # Require admin access for token management -require_admin = PermissionChecker(feature_id="settings", required_level=FeatureAccessLevel.READ_WRITE) +require_admin = PermissionChecker(feature_id="settings-mcp", required_level=FeatureAccessLevel.READ_WRITE) @router.post( diff --git a/src/backend/src/routes/settings_routes.py b/src/backend/src/routes/settings_routes.py index 3f5ab81d..6b8867bb 100644 --- a/src/backend/src/routes/settings_routes.py +++ b/src/backend/src/routes/settings_routes.py @@ -26,6 +26,8 @@ from ..common.config import get_settings from ..common.sanitization import sanitize_markdown_input from ..common.search_config_loader import get_search_config_loader +from ..common.authorization import PermissionChecker +from ..common.features import FeatureAccessLevel # Configure logging from src.common.logging import get_logger @@ -38,7 +40,10 @@ ROLE_NOT_FOUND = "Role not found" @router.get('/settings') -async def get_settings_route(manager: SettingsManager = Depends(get_settings_manager)): +async def get_settings_route( + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-general', FeatureAccessLevel.READ_ONLY)), +): """Get all settings including available job clusters""" try: settings_data = manager.get_settings() # Renamed variable to avoid conflict @@ -55,7 +60,8 @@ async def update_settings( audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, settings_payload: dict, # Renamed to avoid conflict with module - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-general', FeatureAccessLevel.READ_WRITE)), ): """Update settings""" success = False @@ -192,7 +198,8 @@ async def create_role( audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, role_data: AppRoleCreate = Body(..., embed=False), - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-roles', FeatureAccessLevel.ADMIN)), ): """Create a new application role.""" success = False @@ -252,7 +259,8 @@ async def update_role( audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, role_data: AppRole = Body(..., embed=False), - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-roles', FeatureAccessLevel.ADMIN)), ): """Update an existing application role.""" success = False @@ -300,7 +308,8 @@ async def delete_role( db: DBSessionDep, audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-roles', FeatureAccessLevel.ADMIN)), ): """Delete an application role.""" success = False @@ -343,7 +352,8 @@ async def handle_role_request_decision( request_data: HandleRoleRequest = Body(...), settings_manager: SettingsManager = Depends(get_settings_manager), notifications_manager: NotificationsManager = Depends(get_notifications_manager), - change_log_manager = Depends(get_change_log_manager) + change_log_manager = Depends(get_change_log_manager), + _: bool = Depends(PermissionChecker('settings-roles', FeatureAccessLevel.ADMIN)), ): """Handles the admin decision (approve/deny) for a role access request.""" try: @@ -873,7 +883,9 @@ async def get_database_schema(manager: SettingsManager = Depends(get_settings_ma # --- Search Configuration --- @router.get('/settings/search-config', response_model=SearchConfigResponse) -async def get_search_config(): +async def get_search_config( + _: bool = Depends(PermissionChecker('settings-search', FeatureAccessLevel.READ_ONLY)), +): """ Get the current search configuration. @@ -899,7 +911,8 @@ async def update_search_config( audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, config_update: SearchConfigUpdate = Body(...), - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-search', FeatureAccessLevel.ADMIN)), ): """ Update the search configuration. @@ -987,7 +1000,8 @@ async def rebuild_search_index( db: DBSessionDep, audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-search', FeatureAccessLevel.ADMIN)), ): """ Rebuild the search index. @@ -1060,7 +1074,8 @@ def _field_config_to_dict(field) -> dict: @router.get('/settings/git/status') async def get_git_status( include_diffs: bool = False, - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-git', FeatureAccessLevel.READ_ONLY)), ): """Get the current status of the Git repository. @@ -1092,7 +1107,8 @@ async def clone_git_repository( db: DBSessionDep, audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-git', FeatureAccessLevel.READ_WRITE)), ): """Clone the Git repository to the UC Volume. @@ -1145,7 +1161,8 @@ async def pull_git_repository( db: DBSessionDep, audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-git', FeatureAccessLevel.READ_WRITE)), ): """Pull latest changes from the remote Git repository.""" success = False @@ -1185,7 +1202,8 @@ async def pull_git_repository( @router.get('/settings/git/diff') async def get_git_diff( - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-git', FeatureAccessLevel.READ_ONLY)), ): """Get detailed diff of all pending changes in the Git repository.""" try: @@ -1218,7 +1236,8 @@ async def push_git_repository( audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, commit_message: Optional[str] = Body(None, embed=True), - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-git', FeatureAccessLevel.READ_WRITE)), ): """Commit and push all pending changes to the remote Git repository. @@ -1265,7 +1284,8 @@ async def push_git_repository( @router.get('/settings/delivery/status') async def get_delivery_status( - manager: SettingsManager = Depends(get_settings_manager) + manager: SettingsManager = Depends(get_settings_manager), + _: bool = Depends(PermissionChecker('settings-delivery', FeatureAccessLevel.READ_ONLY)), ): """Get the current delivery mode configuration and status.""" try: @@ -1303,7 +1323,8 @@ async def get_delivery_status( async def get_pending_delivery_tasks( db: DBSessionDep, change_type: Optional[str] = None, - notifications_manager = Depends(get_notifications_manager) + notifications_manager = Depends(get_notifications_manager), + _: bool = Depends(PermissionChecker('settings-delivery', FeatureAccessLevel.READ_ONLY)), ): """Get pending manual delivery tasks (notifications).""" try: @@ -1329,7 +1350,8 @@ async def complete_delivery_task( audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, notes: Optional[str] = Body(None, embed=True), - notifications_manager = Depends(get_notifications_manager) + notifications_manager = Depends(get_notifications_manager), + _: bool = Depends(PermissionChecker('settings-delivery', FeatureAccessLevel.READ_WRITE)), ): """Mark a manual delivery task as completed. diff --git a/src/backend/src/routes/workflows_routes.py b/src/backend/src/routes/workflows_routes.py index 6588f3ef..a5fa3ab5 100644 --- a/src/backend/src/routes/workflows_routes.py +++ b/src/backend/src/routes/workflows_routes.py @@ -64,7 +64,7 @@ async def list_workflows( is_active: Optional[bool] = Query(None, description="Filter by active status"), workflow_type: Optional[str] = Query(None, description="Filter by workflow_type: process | approval"), manager: WorkflowsManager = Depends(get_workflows_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ) -> WorkflowListResponse: """List all process workflows (or approval workflows when workflow_type=approval). @@ -89,7 +89,7 @@ async def list_workflows( async def get_step_types( request: Request, manager: WorkflowsManager = Depends(get_workflows_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ) -> List[StepTypeSchema]: """Get schemas for all available step types.""" return manager.get_step_type_schemas() @@ -243,7 +243,7 @@ def _trigger_group(value: str) -> str: @router.get("/trigger-types", response_model=List[Dict[str, Any]]) async def get_trigger_types( request: Request, - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ) -> List[Dict[str, Any]]: """Get the UI catalog of all trigger types. @@ -273,7 +273,7 @@ async def list_executions( status: Optional[str] = Query(None, description="Filter by status"), limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ) -> WorkflowExecutionListResponse: """List workflow executions.""" if workflow_id: @@ -367,7 +367,7 @@ async def list_compliance_policies_for_workflows( @router.get("/roles") async def list_roles_for_workflows( db: DBSessionDep, - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ) -> List[Dict[str, Any]]: """List roles available for workflow approver/recipient selection. @@ -416,7 +416,7 @@ async def list_roles_for_workflows( async def get_role_by_id( role_id: str, db: DBSessionDep, - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ) -> Dict[str, Any]: """Get a single role by UUID for display purposes.""" from src.db_models.settings import AppRoleDb @@ -450,7 +450,7 @@ async def get_role_by_id( @router.get("/http-connections") async def list_http_connections_for_workflows( request: Request, - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ) -> List[Dict[str, Any]]: """List Unity Catalog HTTP connections for webhook step configuration. @@ -706,7 +706,7 @@ async def create_workflow( audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, manager: WorkflowsManager = Depends(get_workflows_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_WRITE)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_WRITE)), ) -> ProcessWorkflow: """Create a new workflow.""" user_email = current_user.email if current_user else None @@ -740,7 +740,7 @@ async def update_workflow( audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, manager: WorkflowsManager = Depends(get_workflows_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_WRITE)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_WRITE)), ) -> ProcessWorkflow: """Update an existing workflow.""" user_email = current_user.email if current_user else None @@ -789,7 +789,7 @@ async def delete_workflow( audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, manager: WorkflowsManager = Depends(get_workflows_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.ADMIN)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.ADMIN)), ) -> dict: """Delete a workflow (non-default only).""" # Check if it's a default workflow @@ -830,7 +830,7 @@ async def toggle_workflow_active( current_user: AuditCurrentUserDep, is_active: bool = Query(..., description="New active status"), manager: WorkflowsManager = Depends(get_workflows_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_WRITE)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_WRITE)), ) -> ProcessWorkflow: """Toggle workflow active status.""" user_email = current_user.email if current_user else None @@ -861,7 +861,7 @@ async def duplicate_workflow( current_user: AuditCurrentUserDep, new_name: str = Query(..., description="Name for the duplicated workflow"), manager: WorkflowsManager = Depends(get_workflows_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_WRITE)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_WRITE)), ) -> ProcessWorkflow: """Duplicate an existing workflow.""" user_email = current_user.email if current_user else None @@ -895,7 +895,7 @@ async def execute_workflow( entity_name: Optional[str] = Query(None, description="Entity name"), manager: WorkflowsManager = Depends(get_workflows_manager), executor: WorkflowExecutor = Depends(get_workflow_executor), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_WRITE)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_WRITE)), ) -> WorkflowExecution: """Manually execute a workflow.""" workflow = manager.get_workflow(workflow_id) @@ -941,7 +941,7 @@ async def validate_workflow( request: Request, workflow: ProcessWorkflowCreate, manager: WorkflowsManager = Depends(get_workflows_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ) -> WorkflowValidationResult: """Validate a workflow definition.""" return manager.validate_workflow(workflow) @@ -955,7 +955,7 @@ async def load_default_workflows( current_user: AuditCurrentUserDep, update_existing: bool = False, manager: WorkflowsManager = Depends(get_workflows_manager), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.ADMIN)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.ADMIN)), ) -> dict: """Load default workflows from YAML (admin only). @@ -1047,7 +1047,7 @@ async def resume_workflow_execution( audit_manager: AuditManagerDep, current_user: AuditCurrentUserDep, executor: WorkflowExecutor = Depends(get_workflow_executor), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_WRITE)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_WRITE)), ) -> Dict[str, Any]: """Resume a paused workflow execution after approval decision. @@ -1121,7 +1121,7 @@ async def get_paused_executions_for_entity( db: DBSessionDep, entity_type: str = Query(..., description="Entity type (e.g., 'data_contract', 'dataset')"), entity_id: str = Query(..., description="Entity ID"), - _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), + _: bool = Depends(PermissionChecker('settings-workflows', FeatureAccessLevel.READ_ONLY)), ) -> Dict[str, Any]: """Find paused workflow executions for a specific entity. diff --git a/src/backend/src/tests/unit/test_settings_manager.py b/src/backend/src/tests/unit/test_settings_manager.py index 23309dc3..10b89ce1 100644 --- a/src/backend/src/tests/unit/test_settings_manager.py +++ b/src/backend/src/tests/unit/test_settings_manager.py @@ -312,15 +312,48 @@ def test_get_settings_returns_dict(self, manager): assert isinstance(result, dict) def test_get_features_with_access_levels(self, manager, db_session): - """Test getting features with their access levels.""" + """Test getting features with their access levels and group bucket.""" # Act result = manager.get_features_with_access_levels() # Assert assert isinstance(result, dict) - # Should contain feature configurations assert len(result) > 0 + # Every entry has the expected shape + for feature_id, conf in result.items(): + assert 'name' in conf, f"{feature_id} is missing 'name'" + assert 'allowed_levels' in conf, f"{feature_id} is missing 'allowed_levels'" + assert 'group' in conf, f"{feature_id} is missing 'group'" + assert conf['group'] in {'Discover', 'Build', 'Govern', 'Deploy', 'Settings', 'Other'} + + # `settings` now offers the four-level scale (not Admin-only) + assert 'settings' in result + assert result['settings']['group'] == 'Settings' + assert 'Read-only' in result['settings']['allowed_levels'] + assert 'Read/Write' in result['settings']['allowed_levels'] + assert 'Admin' in result['settings']['allowed_levels'] + + # Each Settings sub-page has its own dedicated permission ID + expected_subpage_ids = { + 'settings-data-domains', 'settings-business-roles', + 'settings-delivery-methods', 'settings-asset-types', + 'settings-teams', 'settings-projects', + 'settings-certification-levels', 'settings-general', + 'settings-ui', 'settings-tags', 'settings-connectors', + 'settings-git', 'settings-mcp', 'settings-semantic-models', + 'settings-search', 'settings-jobs', 'settings-delivery', + 'settings-workflows', 'settings-roles', 'settings-audit', + } + missing = expected_subpage_ids - set(result.keys()) + assert not missing, f"Missing settings sub-page IDs: {missing}" + + for sub_id in expected_subpage_ids: + assert result[sub_id]['group'] == 'Settings' + assert 'Read-only' in result[sub_id]['allowed_levels'] + assert 'Read/Write' in result[sub_id]['allowed_levels'] + assert 'Admin' in result[sub_id]['allowed_levels'] + # ===================================================================== # Role Count Tests # ===================================================================== diff --git a/src/backend/src/utils/startup_tasks.py b/src/backend/src/utils/startup_tasks.py index 2a5e8b98..9c857a8a 100644 --- a/src/backend/src/utils/startup_tasks.py +++ b/src/backend/src/utils/startup_tasks.py @@ -381,7 +381,12 @@ def initialize_managers(app: FastAPI): # --- Ensure default roles exist using the manager method --- app.state.settings_manager.ensure_default_roles_exist() - + + # --- Backfill Admin role with ADMIN on any newly-added feature IDs --- + # Idempotent migration; covers the settings-* sub-page permissions + # added by the Settings permissions refactor and any future additions. + app.state.settings_manager.upgrade_admin_role_for_new_features() + # --- Ensure default team and project exist for admins --- app.state.settings_manager.ensure_default_team_and_project() diff --git a/src/frontend/src/components/settings/connectors-settings.tsx b/src/frontend/src/components/settings/connectors-settings.tsx index 5a948cff..36e79d5b 100644 --- a/src/frontend/src/components/settings/connectors-settings.tsx +++ b/src/frontend/src/components/settings/connectors-settings.tsx @@ -29,7 +29,7 @@ export default function ConnectorsSettings() { const { toast } = useToast(); const { hasPermission } = usePermissions(); - const hasWriteAccess = hasPermission('settings', FeatureAccessLevel.READ_WRITE); + const hasWriteAccess = hasPermission('settings-connectors', FeatureAccessLevel.READ_WRITE); const [connections, setConnections] = useState([]); const [isLoading, setIsLoading] = useState(false); diff --git a/src/frontend/src/components/settings/delivery-settings.tsx b/src/frontend/src/components/settings/delivery-settings.tsx index a0f1b2f8..88556b3a 100644 --- a/src/frontend/src/components/settings/delivery-settings.tsx +++ b/src/frontend/src/components/settings/delivery-settings.tsx @@ -21,7 +21,7 @@ export default function DeliverySettings() { const { hasPermission } = usePermissions(); const { toast } = useToast(); - const hasWriteAccess = hasPermission('settings', FeatureAccessLevel.READ_WRITE); + const hasWriteAccess = hasPermission('settings-delivery', FeatureAccessLevel.READ_WRITE); const [settings, setSettings] = useState({ deliveryModeDirect: false, diff --git a/src/frontend/src/components/settings/general-settings.tsx b/src/frontend/src/components/settings/general-settings.tsx index 83b84fcf..0b03c802 100644 --- a/src/frontend/src/components/settings/general-settings.tsx +++ b/src/frontend/src/components/settings/general-settings.tsx @@ -29,7 +29,7 @@ export default function GeneralSettings() { const { hasPermission } = usePermissions(); const { toast } = useToast(); - const hasWriteAccess = hasPermission('settings', FeatureAccessLevel.READ_WRITE); + const hasWriteAccess = hasPermission('settings-general', FeatureAccessLevel.READ_WRITE); const [settings, setSettings] = useState({ enableBackgroundJobs: false, diff --git a/src/frontend/src/components/settings/git-settings.tsx b/src/frontend/src/components/settings/git-settings.tsx index 527cf0c8..cedd8338 100644 --- a/src/frontend/src/components/settings/git-settings.tsx +++ b/src/frontend/src/components/settings/git-settings.tsx @@ -52,7 +52,7 @@ export default function GitSettings() { const { t } = useTranslation(['settings', 'common']); const { toast } = useToast(); const { hasPermission } = usePermissions(); - const hasWriteAccess = hasPermission('settings', FeatureAccessLevel.READ_WRITE); + const hasWriteAccess = hasPermission('settings-git', FeatureAccessLevel.READ_WRITE); const [settings, setSettings] = useState({ gitRepoUrl: '', diff --git a/src/frontend/src/components/settings/role-form-dialog.tsx b/src/frontend/src/components/settings/role-form-dialog.tsx index 6a8b654d..0b7ac031 100644 --- a/src/frontend/src/components/settings/role-form-dialog.tsx +++ b/src/frontend/src/components/settings/role-form-dialog.tsx @@ -11,7 +11,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Loader2, AlertCircle } from 'lucide-react'; -import { AppRole, FeatureConfig, FeatureAccessLevel, HomeSection, ApprovalEntity, NO_ROLE_SENTINEL } from '@/types/settings'; +import { AppRole, FeatureConfig, FeatureAccessLevel, HomeSection, ApprovalEntity, NO_ROLE_SENTINEL, PermissionGroup, PERMISSION_GROUP_ORDER } from '@/types/settings'; import { useApi } from '@/hooks/use-api'; import { useToast } from '@/hooks/use-toast'; import { ACCESS_LEVEL_ORDER } from '../../lib/permissions'; @@ -19,6 +19,38 @@ import { features as orderedFeatures } from '@/config/features'; // Import the o import { usePermissions } from '@/stores/permissions-store'; import { Checkbox } from '@/components/ui/checkbox'; +// Order within the Settings group should mirror the Settings sidebar +// (Reference Data → Configuration → Integrations → Operations → Access Control) +// rather than alphabetical. Anything not listed here falls to the bottom. +const SETTINGS_GROUP_ORDER: string[] = [ + // Reference Data + 'settings-data-domains', + 'settings-business-roles', + 'settings-delivery-methods', + 'settings-asset-types', + 'settings-teams', + 'settings-projects', + 'settings-certification-levels', + // Configuration + 'settings-general', + 'settings-ui', + 'settings-tags', + 'settings-connectors', + // Integrations + 'settings-git', + 'settings-mcp', + 'settings-semantic-models', + 'settings-search', + // Operations + 'settings-jobs', + 'settings-delivery', + 'settings-workflows', + // Access Control + 'settings-roles', + 'settings-audit', + // Parent settings gate sits at the very top of the group +]; + interface RoleFormDialogProps { isOpen: boolean; onOpenChange: (open: boolean) => void; @@ -415,107 +447,116 @@ const RoleFormDialog: React.FC = ({
- {/* Feature Permissions */} + {/* Feature Permissions, grouped by sidebar area */}

{t('roles.permissions.featurePermissions.title')}

{t('roles.permissions.featurePermissions.description')}

-
- {orderedFeatures.map((feature) => { - const featureConf = featuresConfig[feature.id]; - if (!featureConf) { - console.warn(`No backend config found for feature ID: ${feature.id}. Skipping permission setting.`); - return null; - } - const allowedLevels = Array.isArray(featureConf.allowed_levels) ? featureConf.allowed_levels : []; - - return ( -
- -
- ( - - )} - /> -
-
- ); - })} - {/* Backend-only features (not in frontend navigation) */} - {Object.entries(featuresConfig) - .filter(([featureId]) => !orderedFeatures.some(f => f.id === featureId)) - .map(([featureId, featureConf]) => { - const allowedLevels = Array.isArray(featureConf.allowed_levels) ? featureConf.allowed_levels : []; - return ( -
- -
- ( - - )} - /> + + {(() => { + const orderedFeatureById = new Map(orderedFeatures.map(f => [f.id, f])); + // Bucket features into groups defined by the backend config. + const grouped = new Map(); + for (const [id, conf] of Object.entries(featuresConfig)) { + const g = (conf.group ?? 'Other') as PermissionGroup; + if (!grouped.has(g)) grouped.set(g, []); + grouped.get(g)!.push([id, conf]); + } + + const sortByName = (a: [string, FeatureConfig], b: [string, FeatureConfig]) => + (a[1].name || a[0]).localeCompare(b[1].name || b[0]); + + // Settings group preserves sidebar order; other groups sort alphabetically. + const settingsRank = (id: string): number => { + if (id === 'settings') return -1; // parent gate at top + const idx = SETTINGS_GROUP_ORDER.indexOf(id); + return idx === -1 ? Number.MAX_SAFE_INTEGER : idx; + }; + const sortSettings = (a: [string, FeatureConfig], b: [string, FeatureConfig]) => { + const ra = settingsRank(a[0]); + const rb = settingsRank(b[0]); + if (ra !== rb) return ra - rb; + return sortByName(a, b); + }; + + for (const [g, items] of grouped) { + items.sort(g === 'Settings' ? sortSettings : sortByName); + } + + const groupLabelKey = (g: PermissionGroup) => { + switch (g) { + case 'Discover': return 'roles.permissions.groups.discover'; + case 'Build': return 'roles.permissions.groups.build'; + case 'Govern': return 'roles.permissions.groups.govern'; + case 'Deploy': return 'roles.permissions.groups.deploy'; + case 'Settings': return 'roles.permissions.groups.settings'; + case 'Other': return 'roles.permissions.groups.other'; + } + }; + + const renderRow = (featureId: string, featureConf: FeatureConfig) => { + const navFeature = orderedFeatureById.get(featureId); + const label = featureConf.name || navFeature?.name || featureId; + const description = navFeature?.description ?? ''; + const allowedLevels = Array.isArray(featureConf.allowed_levels) ? featureConf.allowed_levels : []; + return ( +
+ +
+ ( + + )} + /> +
-
- ); - }) - } - {Object.keys(featuresConfig).length === 0 && ( -

No features configuration loaded.

- )} -
+ ); + }; + + const sections: React.ReactElement[] = []; + for (const g of PERMISSION_GROUP_ORDER) { + const items = grouped.get(g); + if (!items || items.length === 0) continue; + sections.push( +
+
+ {t(groupLabelKey(g), g)} +
+ {items.map(([id, conf]) => renderRow(id, conf))} +
+ ); + } + + if (Object.keys(featuresConfig).length === 0) { + return

No features configuration loaded.

; + } + return sections; + })()}
diff --git a/src/frontend/src/components/settings/roles-settings.tsx b/src/frontend/src/components/settings/roles-settings.tsx index 2ff7f660..19247fc4 100644 --- a/src/frontend/src/components/settings/roles-settings.tsx +++ b/src/frontend/src/components/settings/roles-settings.tsx @@ -37,7 +37,7 @@ export default function RolesSettings() { const { userInfo } = useUserStore(); // Get user info from user store const userGroups = userInfo?.groups ?? []; // Extract groups, default to empty array - const featureId = 'settings'; // Feature ID for permissions + const featureId = 'settings-roles'; // Feature ID for permissions const canWrite = hasPermission(featureId, FeatureAccessLevel.READ_WRITE); const canAdmin = hasPermission(featureId, FeatureAccessLevel.ADMIN); diff --git a/src/frontend/src/components/settings/search-config-editor.tsx b/src/frontend/src/components/settings/search-config-editor.tsx index 74619e57..12751ede 100644 --- a/src/frontend/src/components/settings/search-config-editor.tsx +++ b/src/frontend/src/components/settings/search-config-editor.tsx @@ -149,7 +149,7 @@ export default function SearchConfigEditor() { const { toast } = useToast(); const { hasPermission } = usePermissions(); - const hasWriteAccess = hasPermission('settings', FeatureAccessLevel.ADMIN); + const hasWriteAccess = hasPermission('settings-search', FeatureAccessLevel.ADMIN); const [config, setConfig] = useState(null); const [isLoading, setIsLoading] = useState(true); diff --git a/src/frontend/src/components/settings/settings-layout.tsx b/src/frontend/src/components/settings/settings-layout.tsx index 1d44a70b..e667092b 100644 --- a/src/frontend/src/components/settings/settings-layout.tsx +++ b/src/frontend/src/components/settings/settings-layout.tsx @@ -1,6 +1,9 @@ -import { NavLink, Outlet } from 'react-router-dom'; +import { NavLink, Outlet, Navigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; +import usePermissionsStore, { usePermissions } from '@/stores/permissions-store'; +import { FeatureAccessLevel } from '@/types/settings'; +import { Loader2 } from 'lucide-react'; import { Settings, Palette, @@ -28,6 +31,7 @@ interface SettingsNavItem { labelKey: string; defaultLabel: string; icon: LucideIcon; + permissionId: string; } interface SettingsNavGroup { @@ -41,56 +45,88 @@ const settingsNavGroups: SettingsNavGroup[] = [ titleKey: 'settings:nav.groups.referenceData', defaultTitle: 'Reference Data', items: [ - { path: '/settings/data-domains', labelKey: 'settings:tabs.dataDomains', defaultLabel: 'Domains', icon: BoxSelect }, - { path: '/settings/business-roles', labelKey: 'settings:tabs.businessRoles', defaultLabel: 'Business Roles', icon: Briefcase }, - { path: '/settings/delivery-methods', labelKey: 'settings:tabs.deliveryMethods', defaultLabel: 'Delivery Methods', icon: Truck }, - { path: '/settings/asset-types', labelKey: 'settings:tabs.assetTypes', defaultLabel: 'Asset Types', icon: Shapes }, - { path: '/settings/teams', labelKey: 'settings:tabs.teams', defaultLabel: 'Teams', icon: UserCheck }, - { path: '/settings/projects', labelKey: 'settings:tabs.projects', defaultLabel: 'Projects', icon: FolderOpen }, - { path: '/settings/certification-levels', labelKey: 'settings:tabs.certificationLevels', defaultLabel: 'Certification Levels', icon: ShieldCheck }, + { path: '/settings/data-domains', labelKey: 'settings:tabs.dataDomains', defaultLabel: 'Domains', icon: BoxSelect, permissionId: 'settings-data-domains' }, + { path: '/settings/business-roles', labelKey: 'settings:tabs.businessRoles', defaultLabel: 'Business Roles', icon: Briefcase, permissionId: 'settings-business-roles' }, + { path: '/settings/delivery-methods', labelKey: 'settings:tabs.deliveryMethods', defaultLabel: 'Delivery Methods', icon: Truck, permissionId: 'settings-delivery-methods' }, + { path: '/settings/asset-types', labelKey: 'settings:tabs.assetTypes', defaultLabel: 'Asset Types', icon: Shapes, permissionId: 'settings-asset-types' }, + { path: '/settings/teams', labelKey: 'settings:tabs.teams', defaultLabel: 'Teams', icon: UserCheck, permissionId: 'settings-teams' }, + { path: '/settings/projects', labelKey: 'settings:tabs.projects', defaultLabel: 'Projects', icon: FolderOpen, permissionId: 'settings-projects' }, + { path: '/settings/certification-levels', labelKey: 'settings:tabs.certificationLevels', defaultLabel: 'Certification Levels', icon: ShieldCheck, permissionId: 'settings-certification-levels' }, ], }, { titleKey: 'settings:nav.groups.configuration', defaultTitle: 'Configuration', items: [ - { path: '/settings/general', labelKey: 'settings:tabs.general', defaultLabel: 'General', icon: Settings }, - { path: '/settings/ui', labelKey: 'settings:tabs.ui', defaultLabel: 'UI', icon: Palette }, - { path: '/settings/tags', labelKey: 'settings:tabs.tags', defaultLabel: 'Tags', icon: Tags }, - { path: '/settings/connectors', labelKey: 'settings:tabs.connectors', defaultLabel: 'Connectors', icon: Plug2 }, + { path: '/settings/general', labelKey: 'settings:tabs.general', defaultLabel: 'General', icon: Settings, permissionId: 'settings-general' }, + { path: '/settings/ui', labelKey: 'settings:tabs.ui', defaultLabel: 'UI', icon: Palette, permissionId: 'settings-ui' }, + { path: '/settings/tags', labelKey: 'settings:tabs.tags', defaultLabel: 'Tags', icon: Tags, permissionId: 'settings-tags' }, + { path: '/settings/connectors', labelKey: 'settings:tabs.connectors', defaultLabel: 'Connectors', icon: Plug2, permissionId: 'settings-connectors' }, ], }, { titleKey: 'settings:nav.groups.integrations', defaultTitle: 'Integrations', items: [ - { path: '/settings/git', labelKey: 'settings:tabs.git', defaultLabel: 'Git', icon: GitBranch }, - { path: '/settings/mcp', labelKey: 'settings:tabs.mcp', defaultLabel: 'MCP', icon: Bot }, - { path: '/settings/semantic-models', labelKey: 'settings:tabs.rdfSources', defaultLabel: 'RDF Sources', icon: Network }, - { path: '/settings/search', labelKey: 'settings:tabs.search', defaultLabel: 'Search', icon: Search }, + { path: '/settings/git', labelKey: 'settings:tabs.git', defaultLabel: 'Git', icon: GitBranch, permissionId: 'settings-git' }, + { path: '/settings/mcp', labelKey: 'settings:tabs.mcp', defaultLabel: 'MCP', icon: Bot, permissionId: 'settings-mcp' }, + { path: '/settings/semantic-models', labelKey: 'settings:tabs.rdfSources', defaultLabel: 'RDF Sources', icon: Network, permissionId: 'settings-semantic-models' }, + { path: '/settings/search', labelKey: 'settings:tabs.search', defaultLabel: 'Search', icon: Search, permissionId: 'settings-search' }, ], }, { titleKey: 'settings:nav.groups.operations', defaultTitle: 'Operations', items: [ - { path: '/settings/jobs', labelKey: 'settings:tabs.jobs', defaultLabel: 'Jobs', icon: Clock }, - { path: '/settings/delivery', labelKey: 'settings:tabs.delivery', defaultLabel: 'Delivery', icon: Briefcase }, - { path: '/settings/workflows', labelKey: 'settings:tabs.workflows', defaultLabel: 'Workflows', icon: GitBranch }, + { path: '/settings/jobs', labelKey: 'settings:tabs.jobs', defaultLabel: 'Jobs', icon: Clock, permissionId: 'settings-jobs' }, + { path: '/settings/delivery', labelKey: 'settings:tabs.delivery', defaultLabel: 'Delivery', icon: Briefcase, permissionId: 'settings-delivery' }, + { path: '/settings/workflows', labelKey: 'settings:tabs.workflows', defaultLabel: 'Workflows', icon: GitBranch, permissionId: 'settings-workflows' }, ], }, { titleKey: 'settings:nav.groups.accessControl', defaultTitle: 'Access Control', items: [ - { path: '/settings/roles', labelKey: 'settings:tabs.roles', defaultLabel: 'App Roles', icon: UserCog }, - { path: '/settings/audit', labelKey: 'settings:tabs.audit', defaultLabel: 'Audit Trail', icon: ScrollText }, + { path: '/settings/roles', labelKey: 'settings:tabs.roles', defaultLabel: 'App Roles', icon: UserCog, permissionId: 'settings-roles' }, + { path: '/settings/audit', labelKey: 'settings:tabs.audit', defaultLabel: 'Audit Trail', icon: ScrollText, permissionId: 'settings-audit' }, ], }, ]; export default function SettingsLayout() { const { t } = useTranslation(['settings']); + const { isLoading: permissionsLoading, hasPermission } = usePermissions(); + // Read internal init flags directly (the hook only exposes the public API). + // We need to distinguish "store not yet initialized" from "initialized and + // user genuinely lacks access" — otherwise we'd redirect on first mount + // before the permissions fetch has even started. + const initAttempted = usePermissionsStore((s) => s._initAttempted); + const isInitializing = usePermissionsStore((s) => s._isInitializing); + + if (permissionsLoading || isInitializing || !initAttempted) { + return ( +
+ +
+ ); + } + + // Layout gate: users below Read-only on `settings` cannot enter the + // Settings area at all. Redirect home rather than render a sidebar shell + // they cannot use. + if (!hasPermission('settings', FeatureAccessLevel.READ_ONLY)) { + return ; + } + + // Filter sidebar items by per-sub-page permission; hide empty groups + const visibleGroups = settingsNavGroups + .map((group) => ({ + ...group, + items: group.items.filter((item) => + hasPermission(item.permissionId, FeatureAccessLevel.READ_ONLY) + ), + })) + .filter((group) => group.items.length > 0); return (
@@ -101,7 +137,7 @@ export default function SettingsLayout() {
- {settingsNavGroups.map((group) => ( + {visibleGroups.map((group) => (

{t(group.titleKey, group.defaultTitle)} diff --git a/src/frontend/src/components/settings/settings-page-wrapper.tsx b/src/frontend/src/components/settings/settings-page-wrapper.tsx index 3a0a78ce..0dfb5f2f 100644 --- a/src/frontend/src/components/settings/settings-page-wrapper.tsx +++ b/src/frontend/src/components/settings/settings-page-wrapper.tsx @@ -3,12 +3,19 @@ import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Loader2, ShieldX } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { usePermissions } from '@/stores/permissions-store'; +import usePermissionsStore, { usePermissions } from '@/stores/permissions-store'; import { FeatureAccessLevel } from '@/types/settings'; import useBreadcrumbStore from '@/stores/breadcrumb-store'; interface SettingsPageWrapperProps { title: string; + /** + * Per-sub-page permission ID (e.g. `settings-general`, `settings-git`). + * Access is granted only if the user holds at least `Read-only` on BOTH + * the parent `settings` feature (the layout gate) AND this specific + * sub-page permission. + */ + permissionId: string; children: React.ReactNode; } @@ -16,13 +23,20 @@ interface SettingsPageWrapperProps { * Shared wrapper for standalone settings pages. * Handles permission check, loading state, and breadcrumb setup. */ -export default function SettingsPageWrapper({ title, children }: SettingsPageWrapperProps) { +export default function SettingsPageWrapper({ title, permissionId, children }: SettingsPageWrapperProps) { const { t } = useTranslation(['settings', 'common']); const { isLoading: permissionsLoading, hasPermission } = usePermissions(); + // Distinguish "store not yet initialized" from "user lacks access" — without + // this guard we'd flash the Access Denied panel on first mount before the + // permissions fetch has even started. + const initAttempted = usePermissionsStore((s) => s._initAttempted); + const isInitializing = usePermissionsStore((s) => s._isInitializing); const setStaticSegments = useBreadcrumbStore((state) => state.setStaticSegments); const setDynamicTitle = useBreadcrumbStore((state) => state.setDynamicTitle); - const hasSettingsAccess = hasPermission('settings', FeatureAccessLevel.READ_ONLY); + const hasLayoutAccess = hasPermission('settings', FeatureAccessLevel.READ_ONLY); + const hasPageAccess = hasPermission(permissionId, FeatureAccessLevel.READ_ONLY); + const hasSettingsAccess = hasLayoutAccess && hasPageAccess; useEffect(() => { setStaticSegments([]); @@ -33,7 +47,7 @@ export default function SettingsPageWrapper({ title, children }: SettingsPageWra }; }, [title, setStaticSegments, setDynamicTitle]); - if (permissionsLoading) { + if (permissionsLoading || isInitializing || !initAttempted) { return (
diff --git a/src/frontend/src/components/settings/ui-customization-settings.tsx b/src/frontend/src/components/settings/ui-customization-settings.tsx index 347a2a82..3e767ba8 100644 --- a/src/frontend/src/components/settings/ui-customization-settings.tsx +++ b/src/frontend/src/components/settings/ui-customization-settings.tsx @@ -37,7 +37,7 @@ export default function UICustomizationSettings() { const { t } = useTranslation(['settings', 'common']); const { toast } = useToast(); const { hasPermission } = usePermissions(); - const hasWriteAccess = hasPermission('settings', FeatureAccessLevel.READ_WRITE); + const hasWriteAccess = hasPermission('settings-ui', FeatureAccessLevel.READ_WRITE); const [settings, setSettings] = useState({ i18nEnabled: true, diff --git a/src/frontend/src/components/ui/user-info.tsx b/src/frontend/src/components/ui/user-info.tsx index 0ae50dce..051374a2 100644 --- a/src/frontend/src/components/ui/user-info.tsx +++ b/src/frontend/src/components/ui/user-info.tsx @@ -76,8 +76,13 @@ export default function UserInfo() { appliedRoleId, setRoleOverride, initializeStore, + hasPermission, } = usePermissions(); + // Hide the Settings menu item when the user lacks the layout gate; + // the route would otherwise redirect them home anyway. + const hasSettingsAccess = hasPermission('settings', FeatureAccessLevel.READ_ONLY); + // Use a string state for the radio group value, mapping null to 'actual' const [radioValue, setRadioValue] = useState(appliedRoleId || 'actual'); @@ -245,10 +250,12 @@ export default function UserInfo() { {t('userMenu.profile')} - navigate('/settings')}> - - {t('userMenu.settings', 'Settings')} - + {hasSettingsAccess && ( + navigate('/settings')}> + + {t('userMenu.settings', 'Settings')} + + )} navigate('/about')}> {t('userMenu.about', 'About')} diff --git a/src/frontend/src/i18n/locales/de/settings.json b/src/frontend/src/i18n/locales/de/settings.json index e1214b34..babb814d 100644 --- a/src/frontend/src/i18n/locales/de/settings.json +++ b/src/frontend/src/i18n/locales/de/settings.json @@ -218,6 +218,14 @@ "featurePermissions": { "title": "Funktionsberechtigungen", "description": "Zugriffsebenen für jede Funktion in der Anwendung konfigurieren." + }, + "groups": { + "discover": "Entdecken", + "build": "Erstellen", + "govern": "Verwalten", + "deploy": "Bereitstellen", + "settings": "Einstellungen", + "other": "Sonstige" } }, "deployment": { diff --git a/src/frontend/src/i18n/locales/en/settings.json b/src/frontend/src/i18n/locales/en/settings.json index 429e9042..c91b57c2 100644 --- a/src/frontend/src/i18n/locales/en/settings.json +++ b/src/frontend/src/i18n/locales/en/settings.json @@ -315,6 +315,14 @@ "featurePermissions": { "title": "Feature Permissions", "description": "Configure access levels for each feature in the application." + }, + "groups": { + "discover": "Discover", + "build": "Build", + "govern": "Govern", + "deploy": "Deploy", + "settings": "Settings", + "other": "Other" } }, "deployment": { diff --git a/src/frontend/src/i18n/locales/es/settings.json b/src/frontend/src/i18n/locales/es/settings.json index fd501306..ca139c5f 100644 --- a/src/frontend/src/i18n/locales/es/settings.json +++ b/src/frontend/src/i18n/locales/es/settings.json @@ -218,6 +218,14 @@ "featurePermissions": { "title": "Permisos de funciones", "description": "Configurar niveles de acceso para cada función en la aplicación." + }, + "groups": { + "discover": "Descubrir", + "build": "Construir", + "govern": "Gobernar", + "deploy": "Desplegar", + "settings": "Ajustes", + "other": "Otros" } }, "deployment": { diff --git a/src/frontend/src/i18n/locales/fr/settings.json b/src/frontend/src/i18n/locales/fr/settings.json index 59dbf880..c0ae54f7 100644 --- a/src/frontend/src/i18n/locales/fr/settings.json +++ b/src/frontend/src/i18n/locales/fr/settings.json @@ -218,6 +218,14 @@ "featurePermissions": { "title": "Autorisations de fonctionnalités", "description": "Configurer les niveaux d'accès pour chaque fonctionnalité de l'application." + }, + "groups": { + "discover": "Découvrir", + "build": "Construire", + "govern": "Gouverner", + "deploy": "Déployer", + "settings": "Paramètres", + "other": "Autres" } }, "deployment": { diff --git a/src/frontend/src/i18n/locales/it/settings.json b/src/frontend/src/i18n/locales/it/settings.json index 44bc0731..45bcf171 100644 --- a/src/frontend/src/i18n/locales/it/settings.json +++ b/src/frontend/src/i18n/locales/it/settings.json @@ -218,6 +218,14 @@ "featurePermissions": { "title": "Permessi delle funzionalità", "description": "Configurare i livelli di accesso per ogni funzionalità nell'applicazione." + }, + "groups": { + "discover": "Scopri", + "build": "Costruisci", + "govern": "Governa", + "deploy": "Distribuisci", + "settings": "Impostazioni", + "other": "Altro" } }, "deployment": { diff --git a/src/frontend/src/i18n/locales/ja/settings.json b/src/frontend/src/i18n/locales/ja/settings.json index a9639e4b..38f59d81 100644 --- a/src/frontend/src/i18n/locales/ja/settings.json +++ b/src/frontend/src/i18n/locales/ja/settings.json @@ -218,6 +218,14 @@ "featurePermissions": { "title": "機能の権限", "description": "アプリケーション内の各機能のアクセスレベルを設定します。" + }, + "groups": { + "discover": "発見", + "build": "構築", + "govern": "管理", + "deploy": "展開", + "settings": "設定", + "other": "その他" } }, "deployment": { diff --git a/src/frontend/src/i18n/locales/nl/settings.json b/src/frontend/src/i18n/locales/nl/settings.json index 3f4d609b..95a5493a 100644 --- a/src/frontend/src/i18n/locales/nl/settings.json +++ b/src/frontend/src/i18n/locales/nl/settings.json @@ -218,6 +218,14 @@ "featurePermissions": { "title": "Functie Machtigingen", "description": "Configureer toegangsniveaus voor elke functie in de applicatie." + }, + "groups": { + "discover": "Ontdekken", + "build": "Bouwen", + "govern": "Beheren", + "deploy": "Implementeren", + "settings": "Instellingen", + "other": "Overig" } }, "deployment": { diff --git a/src/frontend/src/types/settings.ts b/src/frontend/src/types/settings.ts index f1eb3199..d01a4cc6 100644 --- a/src/frontend/src/types/settings.ts +++ b/src/frontend/src/types/settings.ts @@ -11,9 +11,30 @@ export enum FeatureAccessLevel { ADMIN = "Admin", } +// Permission group buckets — mirror the sidebar nav groups plus a Settings +// bucket and a catch-all `Other` bucket for cross-cutting permissions. +// Used by the role configuration UI to group features by area. +export type PermissionGroup = + | 'Discover' + | 'Build' + | 'Govern' + | 'Deploy' + | 'Settings' + | 'Other'; + +export const PERMISSION_GROUP_ORDER: PermissionGroup[] = [ + 'Discover', + 'Build', + 'Govern', + 'Deploy', + 'Settings', + 'Other', +]; + export interface FeatureConfig { name: string; allowed_levels: FeatureAccessLevel[]; + group?: PermissionGroup; } export enum HomeSection { diff --git a/src/frontend/src/views/asset-types.tsx b/src/frontend/src/views/asset-types.tsx index 5f89739a..cbfd1e2b 100644 --- a/src/frontend/src/views/asset-types.tsx +++ b/src/frontend/src/views/asset-types.tsx @@ -175,7 +175,7 @@ export default function AssetTypesView() { ], [canWrite, canAdmin, t]); return ( - +

diff --git a/src/frontend/src/views/audit-trail.tsx b/src/frontend/src/views/audit-trail.tsx index 2bdcc8ee..3f2cb5c0 100644 --- a/src/frontend/src/views/audit-trail.tsx +++ b/src/frontend/src/views/audit-trail.tsx @@ -169,7 +169,7 @@ export default function AuditTrail() { }; return ( - +

diff --git a/src/frontend/src/views/business-roles.tsx b/src/frontend/src/views/business-roles.tsx index 2f7c2ad1..45464b2b 100644 --- a/src/frontend/src/views/business-roles.tsx +++ b/src/frontend/src/views/business-roles.tsx @@ -168,7 +168,7 @@ export default function BusinessRolesView() { ], [canWrite, canAdmin, t]); return ( - +

diff --git a/src/frontend/src/views/data-domains.tsx b/src/frontend/src/views/data-domains.tsx index a92459c1..a2409528 100644 --- a/src/frontend/src/views/data-domains.tsx +++ b/src/frontend/src/views/data-domains.tsx @@ -288,7 +288,7 @@ export default function DataDomainsView() { ], [canWrite, canAdmin, navigate, pathname, t]); return ( - +

diff --git a/src/frontend/src/views/delivery-methods.tsx b/src/frontend/src/views/delivery-methods.tsx index 6a82d71d..b737c4b9 100644 --- a/src/frontend/src/views/delivery-methods.tsx +++ b/src/frontend/src/views/delivery-methods.tsx @@ -168,7 +168,7 @@ export default function DeliveryMethodsView() { ], [canWrite, canAdmin, t]); return ( - +

diff --git a/src/frontend/src/views/projects.tsx b/src/frontend/src/views/projects.tsx index 821b9eac..5d70a7cf 100644 --- a/src/frontend/src/views/projects.tsx +++ b/src/frontend/src/views/projects.tsx @@ -275,7 +275,7 @@ export default function ProjectsView() { ], [canWrite, canAdmin, t, handleOpenEditDialog]); return ( - +

diff --git a/src/frontend/src/views/settings-certification-levels.tsx b/src/frontend/src/views/settings-certification-levels.tsx index d4452be3..b89c6b72 100644 --- a/src/frontend/src/views/settings-certification-levels.tsx +++ b/src/frontend/src/views/settings-certification-levels.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsCertificationLevelsView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/settings-connectors.tsx b/src/frontend/src/views/settings-connectors.tsx index fd89ca5f..d6096afa 100644 --- a/src/frontend/src/views/settings-connectors.tsx +++ b/src/frontend/src/views/settings-connectors.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsConnectorsView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/settings-delivery.tsx b/src/frontend/src/views/settings-delivery.tsx index f71f47d0..874179fe 100644 --- a/src/frontend/src/views/settings-delivery.tsx +++ b/src/frontend/src/views/settings-delivery.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsDeliveryView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/settings-general.tsx b/src/frontend/src/views/settings-general.tsx index 4d4492f9..fa69e0f8 100644 --- a/src/frontend/src/views/settings-general.tsx +++ b/src/frontend/src/views/settings-general.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsGeneralView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/settings-git.tsx b/src/frontend/src/views/settings-git.tsx index 0edce4ea..dc332e72 100644 --- a/src/frontend/src/views/settings-git.tsx +++ b/src/frontend/src/views/settings-git.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsGitView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/settings-jobs.tsx b/src/frontend/src/views/settings-jobs.tsx index 6db7f00d..d003d62b 100644 --- a/src/frontend/src/views/settings-jobs.tsx +++ b/src/frontend/src/views/settings-jobs.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsJobsView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/settings-mcp.tsx b/src/frontend/src/views/settings-mcp.tsx index 82376c64..43fef7d2 100644 --- a/src/frontend/src/views/settings-mcp.tsx +++ b/src/frontend/src/views/settings-mcp.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsMcpView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/settings-roles.tsx b/src/frontend/src/views/settings-roles.tsx index cb773865..ac1ebc70 100644 --- a/src/frontend/src/views/settings-roles.tsx +++ b/src/frontend/src/views/settings-roles.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsRolesView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/settings-search.tsx b/src/frontend/src/views/settings-search.tsx index 417c4588..a29647d4 100644 --- a/src/frontend/src/views/settings-search.tsx +++ b/src/frontend/src/views/settings-search.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsSearchView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/settings-semantic-models.tsx b/src/frontend/src/views/settings-semantic-models.tsx index af26ee34..79eff1ef 100644 --- a/src/frontend/src/views/settings-semantic-models.tsx +++ b/src/frontend/src/views/settings-semantic-models.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsSemanticModelsView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/settings-tags.tsx b/src/frontend/src/views/settings-tags.tsx index 89a44a25..a1078724 100644 --- a/src/frontend/src/views/settings-tags.tsx +++ b/src/frontend/src/views/settings-tags.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsTagsView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/settings-ui.tsx b/src/frontend/src/views/settings-ui.tsx index 8caa7968..96ef3f3a 100644 --- a/src/frontend/src/views/settings-ui.tsx +++ b/src/frontend/src/views/settings-ui.tsx @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; export default function SettingsUiView() { const { t } = useTranslation(['settings']); return ( - + ); diff --git a/src/frontend/src/views/teams.tsx b/src/frontend/src/views/teams.tsx index f046dee1..da5da7f7 100644 --- a/src/frontend/src/views/teams.tsx +++ b/src/frontend/src/views/teams.tsx @@ -283,7 +283,7 @@ export default function TeamsView() { ], [canWrite, canAdmin, getDomainName, navigate, t, handleOpenEditDialog]); return ( - +

diff --git a/src/frontend/src/views/workflows.tsx b/src/frontend/src/views/workflows.tsx index 92c5893f..1cd75f8d 100644 --- a/src/frontend/src/views/workflows.tsx +++ b/src/frontend/src/views/workflows.tsx @@ -1142,7 +1142,7 @@ export default function Workflows() { useEffect(() => { setStatsFilter('all'); }, [workflowTypeFilter]); return ( - +