From 5166c0f6c03c8dd1e90b61891b5ad26d1d799279 Mon Sep 17 00:00:00 2001 From: "Liam Verdejo (ES)" Date: Fri, 28 Aug 2026 11:09:16 +0200 Subject: [PATCH 1/5] Feat: Review roles --- .github/copilot-instructions.md | 21 ++- CLAUDE.md | 26 ++- bun.lock | 101 ++++++++++++ src/lib/components/RoleDetail.svelte | 28 ++-- src/lib/database/schemas.ts | 10 +- src/lib/permissions/index.test.ts | 17 +- src/lib/permissions/index.ts | 20 +-- src/modules/auth/application/auth.service.ts | 4 +- .../auth/application/cancan.service.test.ts | 154 ++++++++++++++++++ .../auth/application/cancan.service.ts | 59 ++++++- .../auth/application/role.service.test.ts | 23 ++- src/modules/auth/application/role.service.ts | 88 ++-------- .../auth/domain/role-permissions.data.ts | 78 +++++++++ src/modules/auth/index.ts | 2 +- .../repositories/user-access.repository.ts | 5 + .../code-report-analysis.service.ts | 16 +- .../application/code-report.service.ts | 15 +- .../code-report/domain/risk-weights.data.ts | 13 ++ .../domain/tool-policy-types.data.ts | 15 ++ .../projects/domain/project-defaults.data.ts | 44 +++++ src/modules/projects/domain/project.domain.ts | 54 +----- src/routes/+layout.server.ts | 10 +- src/routes/bootstrap/+page.server.ts | 4 +- .../cluster-settings/orgs/+page.server.ts | 3 +- src/routes/org/[org]/overview/+page.server.ts | 7 +- .../[org]/projects/[slug]/+layout.server.ts | 12 +- .../[slug]/settings/overview/+page.server.ts | 16 +- .../roles-permissions/+page.server.ts | 16 +- .../roles-permissions/[id]/+page.server.ts | 14 +- .../roles-permissions/new/+page.server.ts | 4 +- .../settings/users-groups/+page.server.ts | 36 +++- .../org/[org]/settings/audit/+page.server.ts | 32 +++- .../[org]/settings/projects/+page.server.ts | 42 +++-- .../roles-permissions/+page.server.ts | 35 +++- .../roles-permissions/[id]/+page.server.ts | 26 ++- .../roles-permissions/new/+page.server.ts | 7 +- .../org/[org]/settings/users/+page.server.ts | 49 +++++- 37 files changed, 827 insertions(+), 279 deletions(-) create mode 100644 src/modules/auth/domain/role-permissions.data.ts create mode 100644 src/modules/code-report/domain/risk-weights.data.ts create mode 100644 src/modules/code-report/domain/tool-policy-types.data.ts create mode 100644 src/modules/projects/domain/project-defaults.data.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 43ce44d..6d992ee 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,6 +11,7 @@ Google SSO y SAML son configuracion, no estrategias de autenticacion implementad - SvelteKit 2, Svelte 5, Vite 8 y TypeScript 6 strict - Tailwind CSS 4 y `@lucide/svelte` +- Internacionalizacion con `svelte-i18n` (español e ingles) - Bun como package manager - GitDB como unica capa de persistencia - Vitest, ESLint y Prettier @@ -33,7 +34,9 @@ los aliases ni plugins de Vite/SvelteKit del proyecto. ## Arquitectura La logica vive en `src/modules//` con `domain/`, `application/`, `infrastructure/` e -`index.ts` como composition root. Los modulos actuales son `auth`, `config`, `organization`, +`index.ts` como composition root. El `domain/` contiene entidades (`*.domain.ts`) y datos +centralizados (`*.data.ts`) para constantes: permisos de roles, defaults de proyecto, pesos de +riesgo y mapeos de herramientas. Los modulos actuales son `auth`, `config`, `organization`, `projects`, `storage` y `code-report`. Las rutas deben importar desde el `index.ts` publico. GitDB es la unica fuente de verdad para usuarios, roles, API keys, organizaciones, proyectos, @@ -47,6 +50,22 @@ Los permisos usan `section:action` con scope global, de organizacion o de proyec `can()` o `isAdmin()` desde `$modules/auth`; `locals.user.role` es un objeto, no el string `admin`. +Roles por defecto en `src/modules/auth/domain/role-permissions.data.ts`. Los permisos incluyen scope +como prefijo (ej: `organization:projects:read`, `project:vault:secrets:all`) y se almacenan verbatim sin +transformaciones: +- **Cluster Admin**: vault, openreport, stateiac (todos) +- **Organization Admin**: todos los permisos de org (proyectos, usuarios, roles, backups, audit) +- **Organization Developer**: solo read/create/update de proyectos +- **Project Admin/Developer/Viewer**: permisos granulares por modulo (vault, codereport, stateiac) + +Los permisos de organización se propagan a sus proyectos: un usuario con `organization:projects:read` puede +satisfacer checks `project:project:read` en cualquier proyecto de esa organización, permitiendo delegacion +de autoridad sin crear grants por-proyecto. + +Al crear una organizacion (via bootstrap o cluster-settings), se llama automaticamente a +`roleService.createDefaultOrganizationRoles()`. Al crear un proyecto, se llama a +`roleService.createDefaultProjectRoles()`. Ambas operaciones inicializan sus roles por defecto. + - No guardar `.env`, credenciales Git, API keys ni secretos en el repositorio. - Usar una `GITDB_ENCRYPTION_KEY` larga y aleatoria en produccion. - Mantener scrypt para passwords, HMAC para sesiones y comparaciones timing-safe. diff --git a/CLAUDE.md b/CLAUDE.md index 59424bf..ff4fa9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ The roadmap is in `IDEAS.md`. Do not describe roadmap items as implemented featu - SvelteKit 2, Svelte 5, Vite 8, TypeScript 6 strict mode - Tailwind CSS 4 and `@lucide/svelte` +- Internationalization via `svelte-i18n` (Spanish and English) - Bun package manager; do not use npm or yarn - GitDB (`@getgitops/gitdb`) as the only persistence layer - Vitest, ESLint, and Prettier @@ -37,12 +38,16 @@ the Vite/SvelteKit aliases and plugins used by this project. Business logic lives in `src/modules//`: ```text - domain/ entities and business rules + domain/ entities, business rules, and data constants application/ use cases and services infrastructure/ repositories and adapters index.ts public API and composition root ``` +Domain layers include entity classes (e.g., `*.domain.ts`) and centralized data files (e.g., +`*.data.ts`) for configuration constants: role permissions, default project settings, risk weights, +and tool policy mappings. + Current modules include `auth`, `config`, `organization`, `projects`, `storage`, and `code-report`. Shared infrastructure lives in `src/lib/`. Routes should import module APIs from their `index.ts`, not internal layers. @@ -67,7 +72,24 @@ if (!can(locals.user, 'stateiac:read')) { ``` `locals.user.role` is a session role object, not the string `admin`. Use `isAdmin()` or `can()`. -Keep authorization tests beside changes to permission behavior. + +Default roles and permissions are centralized in `src/modules/auth/domain/role-permissions.data.ts`. +Permissions always include their scope as a prefix (e.g., `organization:projects:read`, +`project:vault:secrets:all`) and are stored verbatim—there is no scope-stripping transformation: +- **Cluster Admin** (`vault:all`, `openreport:all`, `stateiac:all`) +- **Organization Admin** (all org-level permissions: projects, users, roles, backups, audit) +- **Organization Developer** (read/create/update projects only) +- **Project Admin** (all project-level permissions across vault, codereport, stateiac) +- **Project Developer** (read/create/update resources; no deletion/admin) +- **Project Viewer** (read-only across all project modules) + +Organization-level permissions cascade into their projects: a user with `organization:projects:read` can satisfy +a `project:project:read` check on any project in that organization. This allows coarse-grained org roles +to delegate authority downward without creating a separate per-project role grant. + +When creating an organization (via bootstrap or cluster settings), `roleService.createDefaultOrganizationRoles()` is +automatically invoked. When creating a project, `roleService.createDefaultProjectRoles()` is automatically invoked. Both +operations initialize their respective default roles. Keep authorization tests beside changes to permission behavior. ## Security rules diff --git a/bun.lock b/bun.lock index 5755b2a..07faa29 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@lucide/svelte": "^1.34.0", "chart.js": "^4.5.1", "nodemailer": "^9.0.6", + "svelte-i18n": "^4.0.1", }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -154,6 +155,16 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], + + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], + + "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="], + + "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], + "@getgitops/gitdb": ["@getgitops/gitdb@0.8.0", "", {}, "sha512-OYeCchlR1n91UBuDA789+4zgl9i+27DzzLr6djahQlLd7haYrO+LQqaRoJQHVKBmn0SOYPsCY7R+Kc6YeGQOgA=="], "@google-cloud/paginator": ["@google-cloud/paginator@7.0.1", "", { "dependencies": { "extend": "^3.0.2" } }, "sha512-k32cWlHAF8yTgg8rciLI8mPMI6UzuJdKp53YRxISRwMFxUl2FYplvs+Mr2UHxKn0W7rXsqZnUZy73AOJFDP8iA=="], @@ -448,6 +459,8 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "cli-color": ["cli-color@2.0.4", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.64", "es6-iterator": "^2.0.3", "memoizee": "^0.4.15", "timers-ext": "^0.1.7" } }, "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], @@ -460,10 +473,14 @@ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -486,6 +503,14 @@ "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="], + + "es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="], + + "es6-symbol": ["es6-symbol@3.1.4", "", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="], + + "es6-weak-map": ["es6-weak-map@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.46", "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.1" } }, "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA=="], + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -504,6 +529,8 @@ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + "esniff": ["esniff@2.0.1", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="], + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], @@ -518,10 +545,14 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "ext": ["ext@1.7.0", "", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -562,6 +593,10 @@ "globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="], + "globalyzer": ["globalyzer@0.1.0", "", {}, "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + "google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], "google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], @@ -590,6 +625,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -598,6 +635,8 @@ "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], + "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -666,12 +705,16 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lru-queue": ["lru-queue@0.1.0", "", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "memoizee": ["memoizee@0.4.17", "", { "dependencies": { "d": "^1.0.2", "es5-ext": "^0.10.64", "es6-weak-map": "^2.0.3", "event-emitter": "^0.3.5", "is-promise": "^2.2.2", "lru-queue": "^0.1.0", "next-tick": "^1.1.0", "timers-ext": "^0.1.7" } }, "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], @@ -686,6 +729,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -794,12 +839,18 @@ "svelte-eslint-parser": ["svelte-eslint-parser@1.8.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA=="], + "svelte-i18n": ["svelte-i18n@4.0.1", "", { "dependencies": { "cli-color": "^2.0.3", "deepmerge": "^4.2.2", "esbuild": "^0.19.2", "estree-walker": "^2", "intl-messageformat": "^10.5.3", "sade": "^1.8.1", "tiny-glob": "^0.2.9" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5" }, "bin": { "svelte-i18n": "dist/cli.js" } }, "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ=="], + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "teeny-request": ["teeny-request@11.0.1", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "stream-events": "^1.0.5" } }, "sha512-bNr5j2YjSdajgCVsp+8JVjRf1uHIbpNISLeKp/7V1NnVMX3Gh6H/kECNGlm2Q3I68ACODQ0iv6aZ3Rvm8WgLGA=="], + "timers-ext": ["timers-ext@0.1.8", "", { "dependencies": { "es5-ext": "^0.10.64", "next-tick": "^1.1.0" } }, "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww=="], + + "tiny-glob": ["tiny-glob@0.2.9", "", { "dependencies": { "globalyzer": "0.1.0", "globrex": "^0.1.2" } }, "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], @@ -816,6 +867,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], @@ -892,6 +945,8 @@ "svelte-eslint-parser/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "svelte-i18n/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], + "teeny-request/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -915,5 +970,51 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "svelte-i18n/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], + + "svelte-i18n/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], + + "svelte-i18n/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="], + + "svelte-i18n/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="], + + "svelte-i18n/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="], + + "svelte-i18n/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="], + + "svelte-i18n/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="], + + "svelte-i18n/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="], + + "svelte-i18n/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="], + + "svelte-i18n/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="], + + "svelte-i18n/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="], + + "svelte-i18n/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="], + + "svelte-i18n/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="], + + "svelte-i18n/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="], + + "svelte-i18n/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="], + + "svelte-i18n/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="], + + "svelte-i18n/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], + + "svelte-i18n/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], + + "svelte-i18n/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], + + "svelte-i18n/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], + + "svelte-i18n/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="], + + "svelte-i18n/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="], + + "svelte-i18n/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], } } diff --git a/src/lib/components/RoleDetail.svelte b/src/lib/components/RoleDetail.svelte index 5dabb11..49c3e98 100644 --- a/src/lib/components/RoleDetail.svelte +++ b/src/lib/components/RoleDetail.svelte @@ -133,21 +133,17 @@ return [...rootRows, ...childRows]; } - function storedPermissionFor(uiPermission: string): string { - return toStoredPermissionGrant(uiPermission, scope); - } - function toStoredPermissions(uiPermissions: readonly string[]): string[] { - return [...new Set(uiPermissions.map((permission) => storedPermissionFor(permission)))]; + return [ + ...new Set(uiPermissions.map((permission) => toStoredPermissionGrant(permission, scope))), + ]; } function toUiPermissions(storedPermissions: readonly string[]): string[] { const storedSet = new Set(storedPermissions); const rows = getPermissionRows(scope); const mappedPermissions = rows.flatMap((row) => - row.permissions.filter( - (permission) => storedSet.has(permission) || storedSet.has(storedPermissionFor(permission)), - ), + row.permissions.filter((permission) => storedSet.has(permission)), ); return [...new Set(mappedPermissions)]; @@ -434,7 +430,9 @@ {/if}
- +
- +
{$_('common.description')}
- {isCreate ? $_('roleDetail.newRole') : `${roleName || $_('common.role')} ${$_('roleDetail.roleSuffix')}`} + {isCreate + ? $_('roleDetail.newRole') + : `${roleName || $_('common.role')} ${$_('roleDetail.roleSuffix')}`}
@@ -560,7 +562,9 @@ {/key} {:else} - {$_('roleDetail.sectionGroup')} + {$_('roleDetail.sectionGroup')} {/if} diff --git a/src/lib/database/schemas.ts b/src/lib/database/schemas.ts index 6ecc9e9..c238198 100644 --- a/src/lib/database/schemas.ts +++ b/src/lib/database/schemas.ts @@ -27,8 +27,8 @@ export const RoleEntity = entity('roles', { slug: text().notNull(), name: text().notNull(), scope: text().notNull().default('cluster'), - organizationId: uuid(), - projectId: uuid(), + organizationId: uuid().$defaultFn(() => null), + projectId: uuid().$defaultFn(() => null), permissions: json() .notNull() .$defaultFn(() => []), @@ -43,7 +43,7 @@ export const RoleEntity = entity('roles', { export const ApiKeyEntity = entity('api_keys', { id: uuid().primaryKey(), userId: uuid().notNull(), - projectId: uuid(), + projectId: uuid().$defaultFn(() => null), name: text().notNull(), keyPrefix: text().notNull(), keyHash: text().notNull(), @@ -60,8 +60,8 @@ export const UserAccessEntity = entity('user_access', { userId: uuid().notNull(), roleId: uuid().notNull(), scope: text().notNull(), - organizationId: uuid(), - projectId: uuid(), + organizationId: uuid().$defaultFn(() => null), + projectId: uuid().$defaultFn(() => null), status: text().notNull().default('active'), createdAt: timestamp() .notNull() diff --git a/src/lib/permissions/index.test.ts b/src/lib/permissions/index.test.ts index 2c52c87..4dc6fc0 100644 --- a/src/lib/permissions/index.test.ts +++ b/src/lib/permissions/index.test.ts @@ -28,9 +28,7 @@ describe('isValidPermissionGrant', () => { expect(isValidPermissionGrant('openreport:all')).toBe(true); expect(isValidPermissionGrant('project:vault:secrets:import')).toBe(true); expect(isValidPermissionGrant('organization:users:invite')).toBe(true); - expect(isValidPermissionGrant('project:all')).toBe(true); - expect(isValidPermissionGrant('users:invite')).toBe(true); - expect(isValidPermissionGrant('vault:secrets:import')).toBe(true); + expect(isValidPermissionGrant('project:project:all')).toBe(true); }); it('rejects unknown sections, actions, or malformed strings', () => { @@ -38,17 +36,22 @@ describe('isValidPermissionGrant', () => { expect(isValidPermissionGrant('unknown-section:read')).toBe(false); expect(isValidPermissionGrant('vault')).toBe(false); expect(isValidPermissionGrant('*')).toBe(false); + // stripped forms with the scope prefix removed are no longer a valid stored grant — + // CanCanService checks the full catalog string verbatim, with no scope reconstruction. + expect(isValidPermissionGrant('project:all')).toBe(false); + expect(isValidPermissionGrant('users:invite')).toBe(false); + expect(isValidPermissionGrant('vault:secrets:import')).toBe(false); }); }); describe('toStoredPermissionGrant', () => { - it('maps UI permissions to canonical stored grants for the role scope', () => { - expect(toStoredPermissionGrant('project:project:all', 'project')).toBe('project:all'); + it('stores the catalog permission string as-is — CanCanService checks it verbatim', () => { + expect(toStoredPermissionGrant('project:project:all', 'project')).toBe('project:project:all'); expect(toStoredPermissionGrant('project:vault:secrets:read', 'project')).toBe( - 'vault:secrets:read', + 'project:vault:secrets:read', ); expect(toStoredPermissionGrant('organization:users:invite', 'organization')).toBe( - 'users:invite', + 'organization:users:invite', ); }); }); diff --git a/src/lib/permissions/index.ts b/src/lib/permissions/index.ts index 4c04b5f..d71d793 100644 --- a/src/lib/permissions/index.ts +++ b/src/lib/permissions/index.ts @@ -40,24 +40,13 @@ function collectPermissionGrants(section: PermissionConfigSection): string[] { ]; } -function storedPermissionFor(permission: string): string { - const parts = permission.split(':'); - if (parts.length < 3) return permission; - return parts.slice(1).join(':'); -} - +// historically stripped the leading scope segment for a shorter "canonical" stored form, but +// CanCanService.hasPermission compares raw strings with no scope-aware reconstruction — the +// catalog permission string (e.g. 'organization:projects:read') IS the stored/checked grant. export function toStoredPermissionGrant( permission: string, - scope: PermissionScope, + _scope: PermissionScope, ): PermissionGrant { - if (permission.startsWith(`${scope}:${scope}:`)) { - return permission.replace(`${scope}:${scope}:`, `${scope}:`); - } - - if (permission.startsWith(`${scope}:`)) { - return permission.slice(scope.length + 1); - } - return permission; } @@ -67,7 +56,6 @@ const CATALOG_PERMISSION_GRANTS = Object.values(permissionsCatalog.sections).fla export const ALL_PERMISSION_GRANTS: PermissionGrant[] = [ ...CATALOG_PERMISSION_GRANTS, - ...CATALOG_PERMISSION_GRANTS.map((permission) => storedPermissionFor(permission)), ...PERMISSION_SECTIONS.flatMap((section) => PERMISSION_ACTIONS.map((action) => `${section}:${action}` as Permission), ), diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 2a6849e..27daa0d 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -3,9 +3,7 @@ import { PasswordService } from './password.service'; import { SessionService } from './session.service'; import { UserRepository } from '../infrastructure/repositories/user.repository'; import { RoleRepository } from '../infrastructure/repositories/role.repository'; - -const CLUSTER_ADMIN_PERMISSIONS = ['vault:all', 'openreport:all', 'stateiac:all']; -const CLUSTER_USER_PERMISSIONS: string[] = []; +import { CLUSTER_ADMIN_PERMISSIONS, CLUSTER_USER_PERMISSIONS } from '../domain/role-permissions.data'; export class AuthService { constructor( diff --git a/src/modules/auth/application/cancan.service.test.ts b/src/modules/auth/application/cancan.service.test.ts index 073c349..405d420 100644 --- a/src/modules/auth/application/cancan.service.test.ts +++ b/src/modules/auth/application/cancan.service.test.ts @@ -108,6 +108,21 @@ describe('CanCanService', () => { expect(CanCanService.hasPermission(['project:vault:all'], 'vault:delete')).toBe(true); }); + it('matches an N-segment wildcard grant against a narrower same-resource check', () => { + expect( + CanCanService.hasPermission(['organization:projects:all'], 'organization:projects:read'), + ).toBe(true); + expect( + CanCanService.hasPermission(['project:vault:secrets:all'], 'project:vault:secrets:read'), + ).toBe(true); + expect( + CanCanService.hasPermission(['organization:projects:all'], 'organization:users:read'), + ).toBe(false); + expect( + CanCanService.hasPermission(['organization:projects:read'], 'organization:projects:all'), + ).toBe(false); + }); + it('allows a cluster admin without organization or project access rows', async () => { userRepository.rows.set( 'jose', @@ -202,6 +217,145 @@ describe('CanCanService', () => { await expect(service.can('jose', 'stateiac:read', { scope: 'cluster' })).resolves.toBe(true); }); + describe('organization role authority cascades into its projects', () => { + it('lets organization:projects: satisfy the matching project:* check', async () => { + userRepository.rows.set('jose', user({ id: 'jose', role: null })); + projectLookup.organizationsByProjectId.set('kettu', 'gitops'); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'organization', + organizationId: 'gitops', + role: role({ + id: 'org-developer', + slug: 'org-developer', + scope: 'organization', + permissions: ['organization:projects:read', 'organization:projects:update'], + }), + }), + ); + + await expect( + service.can('jose', 'project:project:read', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(true); + await expect( + service.can('jose', 'project:vault:secrets:read', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(true); + await expect( + service.can('jose', 'project:project:update', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(true); + // org-developer has no organization:projects:delete grant, so nothing project-scoped + // that maps to "delete" should be authorized either + await expect( + service.can('jose', 'project:project:delete', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(false); + }); + + it('organization:projects:all satisfies every action inside the org projects', async () => { + userRepository.rows.set('jose', user({ id: 'jose', role: null })); + projectLookup.organizationsByProjectId.set('kettu', 'gitops'); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'organization', + organizationId: 'gitops', + role: role({ + id: 'custom-org-role', + slug: 'custom-org-role', + scope: 'organization', + permissions: ['organization:projects:all'], + }), + }), + ); + + await expect( + service.can('jose', 'project:roles:delete', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(true); + }); + + it('does not let unrelated organization permissions leak into project checks', async () => { + userRepository.rows.set('jose', user({ id: 'jose', role: null })); + projectLookup.organizationsByProjectId.set('kettu', 'gitops'); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'organization', + organizationId: 'gitops', + role: role({ + id: 'org-users-manager', + slug: 'org-users-manager', + scope: 'organization', + permissions: ['organization:users:all'], + }), + }), + ); + + await expect( + service.can('jose', 'project:project:read', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(false); + }); + }); + + describe('canManageOrganization / canViewOrganization', () => { + it('admits an organization-scope role, regardless of its permissions', async () => { + const jose = user({ id: 'jose', role: null }); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'organization', + organizationId: 'gitops', + role: role({ id: 'org-developer', slug: 'org-developer', scope: 'organization' }), + }), + ); + + await expect(service.canManageOrganization(jose, 'gitops')).resolves.toBe(true); + await expect(service.canViewOrganization(jose, 'gitops')).resolves.toBe(true); + }); + + it('does not let a project-only user manage the parent organization', async () => { + const jose = user({ id: 'jose', role: null }); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'project', + projectId: 'kettu', + project: { id: 'kettu', organizationId: 'gitops' }, + role: role({ id: 'project-viewer', slug: 'project-viewer', scope: 'project' }), + }), + ); + + await expect(service.canManageOrganization(jose, 'gitops')).resolves.toBe(false); + }); + + it('lets a project-only user view (not manage) the parent organization', async () => { + const jose = user({ id: 'jose', role: null }); + userAccessRepository.rows.push( + access({ + id: 'access-1', + userId: 'jose', + scope: 'project', + projectId: 'kettu', + project: { id: 'kettu', organizationId: 'gitops' }, + role: role({ id: 'project-viewer', slug: 'project-viewer', scope: 'project' }), + }), + ); + + await expect(service.canViewOrganization(jose, 'gitops')).resolves.toBe(true); + await expect(service.canViewOrganization(jose, 'other-org')).resolves.toBe(false); + }); + + it('denies a user with no access rows at all', async () => { + const jose = user({ id: 'jose', role: null }); + await expect(service.canManageOrganization(jose, 'gitops')).resolves.toBe(false); + await expect(service.canViewOrganization(jose, 'gitops')).resolves.toBe(false); + }); + }); + describe('organizationIdsForUser', () => { it('returns null for a cluster admin (no restriction)', async () => { const jose = user({ id: 'jose', role: role({ id: 'admin', slug: 'admin' }) }); diff --git a/src/modules/auth/application/cancan.service.ts b/src/modules/auth/application/cancan.service.ts index 771ff6d..73a6034 100644 --- a/src/modules/auth/application/cancan.service.ts +++ b/src/modules/auth/application/cancan.service.ts @@ -66,8 +66,29 @@ export class CanCanService { return this.can(user.id, permission, context); } + // coarse organization-scope membership check: gates the /settings area shell and nav visibility. + // does not cascade from project access — per-page/action checks decide what's actually visible. async canManageOrganization(user: PermissionAwareUser, organizationId: string): Promise { - return this.canSessionUser(user, 'stateiac:read', { scope: 'organization', organizationId }); + if (!user?.id) return false; + if (this.isClusterAdmin(user.role ?? null)) return true; + + const access = await this.userAccessRepository.findByUserId(user.id); + return access.some( + (entry) => entry.scope === 'organization' && entry.organizationId === organizationId, + ); + } + + // read-only visibility into an organization: same as canManageOrganization, plus a user whose + // only access is to a project under this organization (they can see the org's overview, not + // manage it — every org-scope action still requires an actual organization:* permission grant). + async canViewOrganization(user: PermissionAwareUser, organizationId: string): Promise { + if (await this.canManageOrganization(user, organizationId)) return true; + if (!user?.id) return false; + + const access = await this.userAccessRepository.findByUserId(user.id); + return access.some( + (entry) => entry.scope === 'project' && entry.project?.organization?.id === organizationId, + ); } async canManageProject( @@ -122,22 +143,39 @@ export class CanCanService { } const access = await this.userAccessRepository.findByUserId(user.id); - const candidateRoles = await this.rolesForContext(access, context); - return candidateRoles.some((role) => this.roleCan(role, permission)); + const candidates = await this.rolesForContext(access, context); + + return candidates.some(({ role, scope }) => { + // an organization-scope role reaching into one of its own projects doesn't hold any + // project:* grant directly (different namespace) — its authority over "this org's + // projects" as a whole (organization:projects:) carries the same action down + // into every project resource, top-down: cluster > organization > project. Only applies + // to the granular project:* vocabulary — legacy flat permissions (stateiac:*, vault:*) + // are scope-agnostic by design and keep matching literally. + if ( + context.scope === 'project' && + scope === 'organization' && + permission.startsWith('project:') + ) { + const action = permission.split(':').pop(); + return this.roleCan(role, `organization:projects:${action}` as PermissionGrant); + } + return this.roleCan(role, permission); + }); } private async rolesForContext( access: UserAccessDomain[], context: CanCanContext, - ): Promise { + ): Promise<{ role: RoleDomain; scope: 'organization' | 'project' }[]> { if (context.scope === 'organization') { return access .filter( (entry) => entry.scope === 'organization' && entry.organizationId === context.organizationId, ) - .map((entry) => entry.role) - .filter((role): role is RoleDomain => Boolean(role)); + .filter((entry): entry is UserAccessDomain & { role: RoleDomain } => Boolean(entry.role)) + .map((entry) => ({ role: entry.role, scope: 'organization' as const })); } if (context.scope !== 'project') return []; @@ -151,8 +189,8 @@ export class CanCanService { if (entry.scope === 'organization') return entry.organizationId === organizationId; return false; }) - .map((entry) => entry.role) - .filter((role): role is RoleDomain => Boolean(role)); + .filter((entry): entry is UserAccessDomain & { role: RoleDomain } => Boolean(entry.role)) + .map((entry) => ({ role: entry.role, scope: entry.scope as 'organization' | 'project' })); } private roleCan(role: PermissionRole, permission: PermissionGrant): boolean { @@ -177,10 +215,15 @@ export class CanCanService { if (!grants || grants.length === 0) return false; const [section] = permission.split(':') as [string, string]; + const lastColon = permission.lastIndexOf(':'); + const resourcePath = lastColon === -1 ? permission : permission.slice(0, lastColon); + const resourceWildcard = `${resourcePath}:all`; + return grants.some( (grant) => grant === permission || grant === `${section}:all` || + grant === resourceWildcard || grant.endsWith(`:${permission}`) || grant.endsWith(`:${section}:all`), ); diff --git a/src/modules/auth/application/role.service.test.ts b/src/modules/auth/application/role.service.test.ts index b6aba65..2613f32 100644 --- a/src/modules/auth/application/role.service.test.ts +++ b/src/modules/auth/application/role.service.test.ts @@ -53,15 +53,29 @@ class FakeUserRepository { } } +class FakeUserAccessRepository { + roleIdCounts = new Map(); + + async countByRoleId(roleId: string) { + return this.roleIdCounts.get(roleId) ?? 0; + } +} + describe('RoleService', () => { let roleRepository: FakeRoleRepository; let userRepository: FakeUserRepository; + let userAccessRepository: FakeUserAccessRepository; let service: RoleService; beforeEach(async () => { roleRepository = new FakeRoleRepository(); userRepository = new FakeUserRepository(); - service = new RoleService(roleRepository as any, userRepository as any); + userAccessRepository = new FakeUserAccessRepository(); + service = new RoleService( + roleRepository as any, + userRepository as any, + userAccessRepository as any, + ); await roleRepository.create({ id: 'admin-id', @@ -185,6 +199,13 @@ describe('RoleService', () => { await expect(service.deleteRole(created.id)).rejects.toThrow(/assigned to existing users/); }); + it('blocks deleting a role assigned via a scoped user_access row', async () => { + const created = await service.createRole({ name: 'Auditor', slug: 'auditor', permissions: [] }); + userAccessRepository.roleIdCounts.set(created.id, 1); + + await expect(service.deleteRole(created.id)).rejects.toThrow(/assigned to existing users/); + }); + it('deletes a role with no users assigned', async () => { const created = await service.createRole({ name: 'Auditor', slug: 'auditor', permissions: [] }); diff --git a/src/modules/auth/application/role.service.ts b/src/modules/auth/application/role.service.ts index b1e49c8..776951c 100644 --- a/src/modules/auth/application/role.service.ts +++ b/src/modules/auth/application/role.service.ts @@ -2,81 +2,15 @@ import crypto from 'crypto'; import { isValidPermissionGrant } from '$lib/permissions'; import type { RoleRepository } from '../infrastructure/repositories/role.repository'; import type { UserRepository } from '../infrastructure/repositories/user.repository'; +import type { UserAccessRepository } from '../infrastructure/repositories/user-access.repository'; import type { RoleScope } from '../domain/role.domain'; - -const ORGANIZATION_ADMIN_PERMISSIONS = [ - 'organization:projects:all', - 'organization:users:all', - 'organization:roles:all', - 'organization:backups:all', - 'organization:server-keys:all', - 'organization:audit:all', -]; - -const ORGANIZATION_DEVELOPER_PERMISSIONS = [ - 'organization:projects:read', - 'organization:projects:create', - 'organization:projects:update', -]; - -const PROJECT_ADMIN_PERMISSIONS = [ - 'project:project:all', - 'project:users:all', - 'project:roles:all', - 'project:server-keys:all', - 'project:audit:all', - 'project:vault:secrets:all', - 'project:vault:environments:all', - 'project:codereport:reports:all', - 'project:codereport:dependencies:all', - 'project:codereport:vulnerabilities:all', - 'project:stateiac:stacks:all', - 'project:stateiac:states:all', - 'project:stateiac:history:all', -]; - -const PROJECT_DEVELOPER_PERMISSIONS = [ - 'project:vault:secrets:read', - 'project:vault:secrets:create', - 'project:vault:secrets:update', - 'project:vault:environments:read', - 'project:vault:environments:create', - 'project:vault:environments:update', - 'project:codereport:reports:read', - 'project:codereport:reports:create', - 'project:codereport:reports:update', - 'project:codereport:dependencies:read', - 'project:codereport:dependencies:create', - 'project:codereport:dependencies:update', - 'project:codereport:vulnerabilities:read', - 'project:codereport:vulnerabilities:create', - 'project:codereport:vulnerabilities:update', - 'project:stateiac:stacks:read', - 'project:stateiac:stacks:create', - 'project:stateiac:stacks:update', - 'project:stateiac:states:read', - 'project:stateiac:states:create', - 'project:stateiac:states:update', - 'project:stateiac:history:read', - 'project:stateiac:history:create', - 'project:stateiac:history:update', -]; - -const PROJECT_VIEWER_PERMISSIONS = [ - 'project:project:read', - 'project:users:read', - 'project:roles:read', - 'project:server-keys:read', - 'project:audit:read', - 'project:vault:secrets:read', - 'project:vault:environments:read', - 'project:codereport:reports:read', - 'project:codereport:dependencies:read', - 'project:codereport:vulnerabilities:read', - 'project:stateiac:stacks:read', - 'project:stateiac:states:read', - 'project:stateiac:history:read', -]; +import { + ORGANIZATION_ADMIN_PERMISSIONS, + ORGANIZATION_DEVELOPER_PERMISSIONS, + PROJECT_ADMIN_PERMISSIONS, + PROJECT_DEVELOPER_PERMISSIONS, + PROJECT_VIEWER_PERMISSIONS, +} from '../domain/role-permissions.data'; export class RoleService { constructor( @@ -85,6 +19,7 @@ export class RoleService { 'findAll' | 'findById' | 'findBySlug' | 'create' | 'update' | 'deleteById' >, private readonly userRepository: Pick, + private readonly userAccessRepository: Pick, ) {} async listRoles(scope: RoleScope = 'cluster', scopeId?: string): Promise { @@ -245,6 +180,11 @@ export class RoleService { throw new Error('Cannot delete a role that is assigned to existing users.'); } + const accessWithRole = await this.userAccessRepository.countByRoleId(id); + if (accessWithRole > 0) { + throw new Error('Cannot delete a role that is assigned to existing users.'); + } + await this.roleRepository.deleteById(id); } diff --git a/src/modules/auth/domain/role-permissions.data.ts b/src/modules/auth/domain/role-permissions.data.ts new file mode 100644 index 0000000..66507d1 --- /dev/null +++ b/src/modules/auth/domain/role-permissions.data.ts @@ -0,0 +1,78 @@ +export const CLUSTER_ADMIN_PERMISSIONS = ['vault:all', 'openreport:all', 'stateiac:all']; + +export const CLUSTER_USER_PERMISSIONS: string[] = []; + +export const ORGANIZATION_ADMIN_PERMISSIONS = [ + 'organization:projects:all', + 'organization:users:all', + 'organization:roles:all', + 'organization:backups:all', + 'organization:server-keys:all', + 'organization:audit:all', +]; + +export const ORGANIZATION_DEVELOPER_PERMISSIONS = [ + 'organization:projects:read', + 'organization:projects:create', + 'organization:projects:update', +]; + +export const PROJECT_ADMIN_PERMISSIONS = [ + 'project:project:all', + 'project:users:all', + 'project:roles:all', + 'project:server-keys:all', + 'project:audit:all', + 'project:vault:secrets:all', + 'project:vault:environments:all', + 'project:codereport:reports:all', + 'project:codereport:dependencies:all', + 'project:codereport:vulnerabilities:all', + 'project:stateiac:stacks:all', + 'project:stateiac:states:all', + 'project:stateiac:history:all', +]; + +export const PROJECT_DEVELOPER_PERMISSIONS = [ + 'project:project:read', + 'project:vault:secrets:read', + 'project:vault:secrets:create', + 'project:vault:secrets:update', + 'project:vault:environments:read', + 'project:vault:environments:create', + 'project:vault:environments:update', + 'project:codereport:reports:read', + 'project:codereport:reports:create', + 'project:codereport:reports:update', + 'project:codereport:dependencies:read', + 'project:codereport:dependencies:create', + 'project:codereport:dependencies:update', + 'project:codereport:vulnerabilities:read', + 'project:codereport:vulnerabilities:create', + 'project:codereport:vulnerabilities:update', + 'project:stateiac:stacks:read', + 'project:stateiac:stacks:create', + 'project:stateiac:stacks:update', + 'project:stateiac:states:read', + 'project:stateiac:states:create', + 'project:stateiac:states:update', + 'project:stateiac:history:read', + 'project:stateiac:history:create', + 'project:stateiac:history:update', +]; + +export const PROJECT_VIEWER_PERMISSIONS = [ + 'project:project:read', + 'project:users:read', + 'project:roles:read', + 'project:server-keys:read', + 'project:audit:read', + 'project:vault:secrets:read', + 'project:vault:environments:read', + 'project:codereport:reports:read', + 'project:codereport:dependencies:read', + 'project:codereport:vulnerabilities:read', + 'project:stateiac:stacks:read', + 'project:stateiac:states:read', + 'project:stateiac:history:read', +]; diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index 31a30cf..cda82d8 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -33,7 +33,7 @@ export const authService = new AuthService( ); export const userService = new UserService(userRepository, roleRepository, passwordService); export const profileService = new ProfileService(userRepository, passwordService); -export const roleService = new RoleService(roleRepository, userRepository); +export const roleService = new RoleService(roleRepository, userRepository, userAccessRepository); export const invitationService = new InvitationService( userRepository, userAccessRepository, diff --git a/src/modules/auth/infrastructure/repositories/user-access.repository.ts b/src/modules/auth/infrastructure/repositories/user-access.repository.ts index 4cdfea4..993f660 100644 --- a/src/modules/auth/infrastructure/repositories/user-access.repository.ts +++ b/src/modules/auth/infrastructure/repositories/user-access.repository.ts @@ -118,4 +118,9 @@ export class UserAccessRepository extends Repository { async deleteById(id: string): Promise { await this.db.delete(UserAccessEntity).where({ id }); } + + async countByRoleId(roleId: string): Promise { + const result = await this.db.select().from(UserAccessEntity).where({ roleId }); + return result.rows.length; + } } diff --git a/src/modules/code-report/application/code-report-analysis.service.ts b/src/modules/code-report/application/code-report-analysis.service.ts index 2097678..fd013f7 100644 --- a/src/modules/code-report/application/code-report-analysis.service.ts +++ b/src/modules/code-report/application/code-report-analysis.service.ts @@ -4,7 +4,8 @@ import type { CodeReportAnalysisDomain } from '../domain/code-report-analysis.do import type { CodeReportGitInfo } from '../domain/code-report-analysis.domain'; import { extractSecrets, extractVulnerabilities } from '$lib/code-report/analysis-summary'; import { evaluatePolicies, type PolicyComplianceReport } from '$lib/code-report/policy-evaluation'; -import type { SecurityPolicy, SecurityPolicyType } from '$lib/code-report/security-policy'; +import type { SecurityPolicy } from '$lib/code-report/security-policy'; +import { TOOL_POLICY_TYPES, DEFAULT_POLICY_TYPES } from '../domain/tool-policy-types.data'; type ServiceLookup = { findById(id: string): Promise<{ id: string; projectId?: string; tags?: string[] } | null>; @@ -14,19 +15,6 @@ type PolicyLookup = { listByProject(projectId: string): Promise; }; -// each tool only produces evidence for some policy types -const TOOL_POLICY_TYPES: Record = { - trivy: ['vulnerabilities', 'license'], - grype: ['vulnerabilities'], - sbom: ['license'], - syft: ['license'], - gitleaks: ['secrets'], - trufflehog: ['secrets'], - coverage: ['code_coverage'], - 'code-coverage': ['code_coverage'], -}; -const DEFAULT_POLICY_TYPES: SecurityPolicyType[] = ['vulnerabilities', 'license']; - export class CodeReportAnalysisService { constructor( private readonly repository: CodeReportAnalysisRepository, diff --git a/src/modules/code-report/application/code-report.service.ts b/src/modules/code-report/application/code-report.service.ts index 6672bea..7c867e4 100644 --- a/src/modules/code-report/application/code-report.service.ts +++ b/src/modules/code-report/application/code-report.service.ts @@ -1,13 +1,7 @@ import crypto from 'crypto'; import { CodeReportServiceRepository } from '../infrastructure/repositories/code-report-service.repository'; import { ProjectService } from '../../projects/application/project.service'; - -export type RiskWeights = { - critical: number; - high: number; - medium: number; - low: number; -}; +import { DEFAULT_RISK_WEIGHTS, type RiskWeights } from '../domain/risk-weights.data'; export type VulnerabilityTotals = { critical: number; @@ -16,13 +10,6 @@ export type VulnerabilityTotals = { low: number; }; -export const DEFAULT_RISK_WEIGHTS: RiskWeights = { - critical: 10, - high: 6, - medium: 3, - low: 1, -}; - type AnalysisCleanup = { deleteAllByService(serviceId: string): Promise; }; diff --git a/src/modules/code-report/domain/risk-weights.data.ts b/src/modules/code-report/domain/risk-weights.data.ts new file mode 100644 index 0000000..1dfde7e --- /dev/null +++ b/src/modules/code-report/domain/risk-weights.data.ts @@ -0,0 +1,13 @@ +export type RiskWeights = { + critical: number; + high: number; + medium: number; + low: number; +}; + +export const DEFAULT_RISK_WEIGHTS: RiskWeights = { + critical: 10, + high: 6, + medium: 3, + low: 1, +}; diff --git a/src/modules/code-report/domain/tool-policy-types.data.ts b/src/modules/code-report/domain/tool-policy-types.data.ts new file mode 100644 index 0000000..f6115b8 --- /dev/null +++ b/src/modules/code-report/domain/tool-policy-types.data.ts @@ -0,0 +1,15 @@ +import type { SecurityPolicyType } from '$lib/code-report/security-policy'; + +// each tool only produces evidence for some policy types +export const TOOL_POLICY_TYPES: Record = { + trivy: ['vulnerabilities', 'license'], + grype: ['vulnerabilities'], + sbom: ['license'], + syft: ['license'], + gitleaks: ['secrets'], + trufflehog: ['secrets'], + coverage: ['code_coverage'], + 'code-coverage': ['code_coverage'], +}; + +export const DEFAULT_POLICY_TYPES: SecurityPolicyType[] = ['vulnerabilities', 'license']; diff --git a/src/modules/projects/domain/project-defaults.data.ts b/src/modules/projects/domain/project-defaults.data.ts new file mode 100644 index 0000000..bde4553 --- /dev/null +++ b/src/modules/projects/domain/project-defaults.data.ts @@ -0,0 +1,44 @@ +export interface ProjectModules { + vault: boolean; + codereport: boolean; + stateiac: boolean; +} + +export const DEFAULT_PROJECT_MODULES: ProjectModules = { + vault: true, + codereport: true, + stateiac: true, +}; + +export interface ProjectSettings { + 'code-report': { + securityRiskMultipliers: { + critical: number; + high: number; + medium: number; + low: number; + }; + tools: { + id: string; + name: string; + description: string; + enabled: boolean; + scanner?: string; + soon?: boolean; + }[]; + }; +} + +export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = { + 'code-report': { + securityRiskMultipliers: { + critical: 10, + high: 6, + medium: 3, + low: 1, + }, + tools: [ + { id: 'trivy', name: 'Trivy', description: 'Comprehensive security scanner', enabled: true }, + ], + }, +}; diff --git a/src/modules/projects/domain/project.domain.ts b/src/modules/projects/domain/project.domain.ts index 48e4d1e..2bfa6f0 100644 --- a/src/modules/projects/domain/project.domain.ts +++ b/src/modules/projects/domain/project.domain.ts @@ -1,56 +1,20 @@ import { Domain } from '$lib/server/domain/domain'; import { OrganizationDomain } from '../../organization/domain/organization.domain'; +import { + DEFAULT_PROJECT_MODULES, + DEFAULT_PROJECT_SETTINGS, + type ProjectModules, + type ProjectSettings, +} from './project-defaults.data'; + +export type { ProjectModules, ProjectSettings }; +export { DEFAULT_PROJECT_MODULES, DEFAULT_PROJECT_SETTINGS }; export interface ProjectStatus { ACTIVE: 'active'; INACTIVE: 'inactive'; } -export interface ProjectModules { - vault: boolean; - codereport: boolean; - stateiac: boolean; -} - -export const DEFAULT_PROJECT_MODULES: ProjectModules = { - vault: true, - codereport: true, - stateiac: true, -}; - -export interface ProjectSettings { - 'code-report': { - securityRiskMultipliers: { - critical: number; - high: number; - medium: number; - low: number; - }; - tools: { - id: string; - name: string; - description: string; - enabled: boolean; - scanner?: string; - soon?: boolean; - }[]; - }; -} - -export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = { - 'code-report': { - securityRiskMultipliers: { - critical: 10, - high: 6, - medium: 3, - low: 1, - }, - tools: [ - { id: 'trivy', name: 'Trivy', description: 'Comprehensive security scanner', enabled: true }, - ] - } -}; - export class ProjectDomain extends Domain { public name: string = ''; public slug: string | null = null; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 1cd1011..cd7318e 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -44,11 +44,11 @@ export async function load({ locals, url }) { .filter((project) => project.status === 'active') .map(async (project) => ({ project, - allowed: await cancanService.canSessionUser(locals.user, 'stateiac:read', { - scope: 'project', - projectId: project.id, - organizationId: project.organization?.id, - }), + allowed: await cancanService.canManageProject( + locals.user, + project.id, + project.organization?.id, + ), })), ) ) diff --git a/src/routes/bootstrap/+page.server.ts b/src/routes/bootstrap/+page.server.ts index 9d8ed44..dac191c 100644 --- a/src/routes/bootstrap/+page.server.ts +++ b/src/routes/bootstrap/+page.server.ts @@ -60,11 +60,13 @@ export const actions = { try { const form = await request.formData(); const { organizationService } = await import('$modules/organization'); - await organizationService.createOrganization({ + const { roleService } = await import('$modules/auth'); + const organization = await organizationService.createOrganization({ name: String(form.get('name') ?? ''), slug: String(form.get('slug') ?? '') || undefined, description: String(form.get('description') ?? '') || undefined, }); + await roleService.createDefaultOrganizationRoles(organization.id); await refreshBootstrapState(); } catch (error: unknown) { diff --git a/src/routes/cluster-settings/orgs/+page.server.ts b/src/routes/cluster-settings/orgs/+page.server.ts index f8a72e2..f7ebd63 100644 --- a/src/routes/cluster-settings/orgs/+page.server.ts +++ b/src/routes/cluster-settings/orgs/+page.server.ts @@ -1,5 +1,5 @@ import { fail } from '@sveltejs/kit'; -import { cancanService } from '$modules/auth'; +import { cancanService, roleService } from '$modules/auth'; import { organizationService } from '$modules/organization'; function errorResponse(error: unknown) { @@ -24,6 +24,7 @@ export const actions = { slug: String(form.get('slug') ?? '') || undefined, description: String(form.get('description') ?? '') || undefined, }); + await roleService.createDefaultOrganizationRoles(organization.id); return { success: true, organization }; } catch (error: unknown) { return errorResponse(error); diff --git a/src/routes/org/[org]/overview/+page.server.ts b/src/routes/org/[org]/overview/+page.server.ts index 5488d09..ec37a1c 100644 --- a/src/routes/org/[org]/overview/+page.server.ts +++ b/src/routes/org/[org]/overview/+page.server.ts @@ -5,12 +5,7 @@ import { cancanService } from '$modules/auth'; export async function load({ locals, parent }) { const { organization } = await parent(); - if ( - !(await cancanService.canSessionUser(locals.user, 'stateiac:read', { - scope: 'organization', - organizationId: organization.id, - })) - ) { + if (!(await cancanService.canViewOrganization(locals.user, organization.id))) { throw error(403, 'Forbidden'); } diff --git a/src/routes/org/[org]/projects/[slug]/+layout.server.ts b/src/routes/org/[org]/projects/[slug]/+layout.server.ts index d00bfb7..c44db7c 100644 --- a/src/routes/org/[org]/projects/[slug]/+layout.server.ts +++ b/src/routes/org/[org]/projects/[slug]/+layout.server.ts @@ -6,28 +6,24 @@ export async function load({ params, locals }) { try { const project = await projectService.getProjectBySlug(params.slug); const organizationId = project.organization?.id; - const canRead = await cancanService.canSessionUser(locals.user, 'stateiac:read', { - scope: 'project', - projectId: project.id, - organizationId, - }); + const canRead = await cancanService.canManageProject(locals.user, project.id, organizationId); if (!canRead) { throw error(403, 'Forbidden'); } const [canCreateVault, canCreateOpenReport, canCreateStateIac] = await Promise.all([ - cancanService.canSessionUser(locals.user, 'vault:create', { + cancanService.canSessionUser(locals.user, 'project:vault:secrets:create', { scope: 'project', projectId: project.id, organizationId, }), - cancanService.canSessionUser(locals.user, 'openreport:create', { + cancanService.canSessionUser(locals.user, 'project:codereport:reports:create', { scope: 'project', projectId: project.id, organizationId, }), - cancanService.canSessionUser(locals.user, 'stateiac:create', { + cancanService.canSessionUser(locals.user, 'project:stateiac:stacks:create', { scope: 'project', projectId: project.id, organizationId, diff --git a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts index f5f9fa3..81af0cf 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts @@ -14,11 +14,11 @@ export async function load({ params, locals }) { throw error(404, 'Project not found'); } - const canRead = await cancanService.canSessionUser(locals.user, 'stateiac:read', { - scope: 'project', - projectId: project.id, - organizationId: project.organization?.id, - }); + const canRead = await cancanService.canManageProject( + locals.user, + project.id, + project.organization?.id, + ); if (!canRead) throw error(403, 'Forbidden'); @@ -32,7 +32,7 @@ export const actions = { const form = await request.formData(); const id = String(form.get('id') ?? ''); const currentProject = await projectService.getProject(id); - const canUpdate = await cancanService.canSessionUser(locals.user, 'stateiac:update', { + const canUpdate = await cancanService.canSessionUser(locals.user, 'project:project:update', { scope: 'project', projectId: currentProject.id, organizationId: currentProject.organization?.id, @@ -66,7 +66,7 @@ export const actions = { const form = await request.formData(); const id = String(form.get('id') ?? ''); const currentProject = await projectService.getProject(id); - const canUpdate = await cancanService.canSessionUser(locals.user, 'stateiac:update', { + const canUpdate = await cancanService.canSessionUser(locals.user, 'project:project:update', { scope: 'project', projectId: currentProject.id, organizationId: currentProject.organization?.id, @@ -89,7 +89,7 @@ export const actions = { const form = await request.formData(); const id = String(form.get('id') ?? ''); const project = await projectService.getProject(id); - const canDelete = await cancanService.canSessionUser(locals.user, 'stateiac:delete', { + const canDelete = await cancanService.canSessionUser(locals.user, 'project:project:delete', { scope: 'project', projectId: project.id, organizationId: project.organization?.id, diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts index 211f931..648fa19 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts @@ -16,7 +16,7 @@ function errorResponse(error: unknown) { async function canManageProjectRole( user: Parameters[0], projectSlug: string, - permission: 'stateiac:create' | 'stateiac:update' | 'stateiac:delete', + permission: 'project:roles:create' | 'project:roles:update' | 'project:roles:delete', ) { const project = await projectService.getProjectBySlug(projectSlug); const allowed = await cancanService.canSessionUser(user, permission, { @@ -38,7 +38,7 @@ export const actions = { const { project, allowed } = await canManageProjectRole( locals.user, params.slug, - 'stateiac:create', + 'project:roles:create', ); if (!allowed) return fail(403, { error: 'Forbidden' }); @@ -58,7 +58,11 @@ export const actions = { }, async updateRole({ request, locals, params }) { - const { allowed } = await canManageProjectRole(locals.user, params.slug, 'stateiac:update'); + const { allowed } = await canManageProjectRole( + locals.user, + params.slug, + 'project:roles:update', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { @@ -74,7 +78,11 @@ export const actions = { }, async deleteRole({ request, locals, params }) { - const { allowed } = await canManageProjectRole(locals.user, params.slug, 'stateiac:delete'); + const { allowed } = await canManageProjectRole( + locals.user, + params.slug, + 'project:roles:delete', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts index 3bc24a4..b6f8bbc 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts @@ -18,7 +18,7 @@ function errorResponse(errorValue: unknown) { async function canManageProjectRole( user: Parameters[0], projectSlug: string, - permission: 'stateiac:create' | 'stateiac:update' | 'stateiac:delete', + permission: 'project:roles:create' | 'project:roles:update' | 'project:roles:delete', ) { const project = await projectService.getProjectBySlug(projectSlug); const allowed = await cancanService.canSessionUser(user, permission, { @@ -39,7 +39,11 @@ export async function load({ parent, params }) { export const actions = { async updateRole({ request, locals, params }) { - const { allowed } = await canManageProjectRole(locals.user, params.slug, 'stateiac:update'); + const { allowed } = await canManageProjectRole( + locals.user, + params.slug, + 'project:roles:update', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { @@ -55,7 +59,11 @@ export const actions = { }, async deleteRole({ request, locals, params }) { - const { allowed } = await canManageProjectRole(locals.user, params.slug, 'stateiac:delete'); + const { allowed } = await canManageProjectRole( + locals.user, + params.slug, + 'project:roles:delete', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts index 99a0c4c..a9ae860 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts @@ -18,7 +18,7 @@ function errorResponse(errorValue: unknown) { async function canManageProjectRole( user: Parameters[0], projectSlug: string, - permission: 'stateiac:create' | 'stateiac:update' | 'stateiac:delete', + permission: 'project:roles:create' | 'project:roles:update' | 'project:roles:delete', ) { const project = await projectService.getProjectBySlug(projectSlug); const allowed = await cancanService.canSessionUser(user, permission, { @@ -39,7 +39,7 @@ export const actions = { const { project, allowed } = await canManageProjectRole( locals.user, params.slug, - 'stateiac:create', + 'project:roles:create', ); if (!allowed) return fail(403, { error: 'Forbidden' }); diff --git a/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.server.ts index f5889f4..4a6eee2 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.server.ts @@ -7,9 +7,17 @@ function errorResponse(error: unknown) { return fail(400, { error: error instanceof Error ? error.message : 'User action failed.' }); } -async function canUpdateProjectUsers(user: AuthenticatedUser | null | undefined, projectSlug: string) { +async function canUpdateProjectUsers( + user: AuthenticatedUser | null | undefined, + projectSlug: string, + permission: + | 'project:users:create' + | 'project:users:update' + | 'project:users:delete' + | 'project:users:invite', +) { const project = await projectService.getProjectBySlug(projectSlug); - const allowed = await cancanService.canSessionUser(user, 'stateiac:update', { + const allowed = await cancanService.canSessionUser(user, permission, { scope: 'project', projectId: project.id, organizationId: project.organization?.id, @@ -29,7 +37,11 @@ export async function load({ parent }) { export const actions = { async addUser({ request, locals, params }) { - const { project, allowed } = await canUpdateProjectUsers(locals.user, params.slug); + const { project, allowed } = await canUpdateProjectUsers( + locals.user, + params.slug, + 'project:users:create', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { @@ -46,7 +58,11 @@ export const actions = { }, async updateUserAccess({ request, locals, params }) { - const { project, allowed } = await canUpdateProjectUsers(locals.user, params.slug); + const { project, allowed } = await canUpdateProjectUsers( + locals.user, + params.slug, + 'project:users:update', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { @@ -65,7 +81,11 @@ export const actions = { }, async removeUserAccess({ request, locals, params }) { - const { project, allowed } = await canUpdateProjectUsers(locals.user, params.slug); + const { project, allowed } = await canUpdateProjectUsers( + locals.user, + params.slug, + 'project:users:delete', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { @@ -82,7 +102,11 @@ export const actions = { }, async resendInvitation({ request, locals, params }) { - const { project, allowed } = await canUpdateProjectUsers(locals.user, params.slug); + const { project, allowed } = await canUpdateProjectUsers( + locals.user, + params.slug, + 'project:users:invite', + ); if (!allowed) return fail(403, { error: 'Forbidden' }); try { diff --git a/src/routes/org/[org]/settings/audit/+page.server.ts b/src/routes/org/[org]/settings/audit/+page.server.ts index 7038645..5933cff 100644 --- a/src/routes/org/[org]/settings/audit/+page.server.ts +++ b/src/routes/org/[org]/settings/audit/+page.server.ts @@ -1,8 +1,20 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { auditService } from '$modules/audit'; +import { cancanService } from '$modules/auth'; +import { organizationService } from '$modules/organization'; -export async function load({ parent, url }) { +export async function load({ parent, url, locals }) { const { organization } = await parent(); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:audit:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + const { events, page, perPage, total, totalPages } = await auditService.listEvents({ organizationId: organization.id, search: url.searchParams.get('search') || undefined, @@ -20,7 +32,17 @@ export async function load({ parent, url }) { } export const actions = { - async viewDiff({ request }) { + async viewDiff({ request, locals, params }) { + const organization = await organizationService.findBySlug(params.org); + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:audit:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + return fail(403, { error: 'Forbidden' }); + } + const form = await request.formData(); const commit = String(form.get('commit') ?? ''); const entity = String(form.get('entity') ?? ''); @@ -33,7 +55,9 @@ export const actions = { const changes = await auditService.getEntityDiff(commit, entity); return { success: true, changes }; } catch (error: unknown) { - return fail(500, { error: error instanceof Error ? error.message : 'Failed to load changes.' }); + return fail(500, { + error: error instanceof Error ? error.message : 'Failed to load changes.', + }); } }, }; diff --git a/src/routes/org/[org]/settings/projects/+page.server.ts b/src/routes/org/[org]/settings/projects/+page.server.ts index b74021c..7a4a9c9 100644 --- a/src/routes/org/[org]/settings/projects/+page.server.ts +++ b/src/routes/org/[org]/settings/projects/+page.server.ts @@ -1,5 +1,5 @@ -import { fail } from '@sveltejs/kit'; -import { cancanService } from '$modules/auth'; +import { error, fail } from '@sveltejs/kit'; +import { cancanService, roleService } from '$modules/auth'; import { organizationService } from '$modules/organization'; import { projectService } from '$modules/projects'; @@ -7,8 +7,18 @@ function errorResponse(error: unknown) { return fail(400, { error: error instanceof Error ? error.message : 'Project action failed.' }); } -export async function load({ params }) { +export async function load({ params, locals }) { const organization = await organizationService.findBySlug(params.org); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:projects:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + const projects = await projectService.listProjectsByOrganization(organization.id); return { organization, projects }; } @@ -18,10 +28,14 @@ export const actions = { try { const form = await request.formData(); const organizationId = String(form.get('organizationId') ?? ''); - const canCreate = await cancanService.canSessionUser(locals.user, 'stateiac:create', { - scope: 'organization', - organizationId, - }); + const canCreate = await cancanService.canSessionUser( + locals.user, + 'organization:projects:create', + { + scope: 'organization', + organizationId, + }, + ); if (!canCreate) return fail(403, { error: 'Forbidden' }); @@ -32,6 +46,7 @@ export const actions = { description: String(form.get('description') ?? '') || undefined, status: String(form.get('status') ?? ''), }); + await roleService.createDefaultProjectRoles(project.id); return { success: true, project }; } catch (error: unknown) { @@ -44,11 +59,14 @@ export const actions = { const form = await request.formData(); const id = String(form.get('id') ?? ''); const project = await projectService.getProject(id); - const canDelete = await cancanService.canSessionUser(locals.user, 'stateiac:delete', { - scope: 'project', - projectId: project.id, - organizationId: project.organization?.id, - }); + const canDelete = await cancanService.canSessionUser( + locals.user, + 'organization:projects:delete', + { + scope: 'organization', + organizationId: project.organization?.id ?? '', + }, + ); if (!canDelete) return fail(403, { error: 'Forbidden' }); diff --git a/src/routes/org/[org]/settings/roles-permissions/+page.server.ts b/src/routes/org/[org]/settings/roles-permissions/+page.server.ts index 3d5da10..dfe4930 100644 --- a/src/routes/org/[org]/settings/roles-permissions/+page.server.ts +++ b/src/routes/org/[org]/settings/roles-permissions/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { cancanService, roleService } from '$modules/auth'; import { organizationService } from '$modules/organization'; @@ -13,8 +13,18 @@ function errorResponse(error: unknown) { return fail(400, { error: error instanceof Error ? error.message : 'Role action failed.' }); } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { organization } = await parent(); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + const roles = await roleService.listRoles('organization', organization.id); return { roles }; } @@ -22,7 +32,12 @@ export async function load({ parent }) { export const actions = { async createRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:create', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -43,7 +58,12 @@ export const actions = { async updateRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:update', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -61,7 +81,12 @@ export const actions = { async deleteRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:delete', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } diff --git a/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts b/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts index e613552..f0f7056 100644 --- a/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts +++ b/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts @@ -15,8 +15,18 @@ function errorResponse(errorValue: unknown) { }); } -export async function load({ parent, params }) { +export async function load({ parent, params, locals }) { const { organization } = await parent(); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + const roles = await roleService.listRoles('organization', organization.id); const role = roles.find((row) => row.id === params.id); if (!role) throw error(404, 'Role not found'); @@ -26,7 +36,12 @@ export async function load({ parent, params }) { export const actions = { async updateRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:update', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -44,7 +59,12 @@ export const actions = { async deleteRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:delete', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } diff --git a/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts b/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts index 6dd471b..0da5ed5 100644 --- a/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts +++ b/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts @@ -23,7 +23,12 @@ export async function load({ parent }) { export const actions = { async createRole({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:roles:create', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } diff --git a/src/routes/org/[org]/settings/users/+page.server.ts b/src/routes/org/[org]/settings/users/+page.server.ts index a78a974..20cc850 100644 --- a/src/routes/org/[org]/settings/users/+page.server.ts +++ b/src/routes/org/[org]/settings/users/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { cancanService, roleService, userAccessService } from '$modules/auth'; import { organizationService } from '$modules/organization'; @@ -6,8 +6,18 @@ function errorResponse(error: unknown) { return fail(400, { error: error instanceof Error ? error.message : 'User action failed.' }); } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { organization } = await parent(); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + const [users, roles] = await Promise.all([ userAccessService.listUsers('organization', organization.id), roleService.listRoles('organization', organization.id), @@ -18,7 +28,12 @@ export async function load({ parent }) { export const actions = { async addUser({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:create', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -39,7 +54,12 @@ export const actions = { async inviteUser({ request, locals, params, url }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:invite', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -60,7 +80,12 @@ export const actions = { async updateUserAccess({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:update', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -81,7 +106,12 @@ export const actions = { async removeUserAccess({ request, locals, params }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:delete', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } @@ -100,7 +130,12 @@ export const actions = { async resendInvitation({ request, locals, params, url }) { const organization = await organizationService.findBySlug(params.org); - if (!(await cancanService.canManageOrganization(locals.user, organization.id))) { + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:users:invite', { + scope: 'organization', + organizationId: organization.id, + })) + ) { return fail(403, { error: 'Forbidden' }); } From 59d3bd4a0cac512909bf8331e339febf116ffd09 Mon Sep 17 00:00:00 2001 From: "Liam Verdejo (ES)" Date: Fri, 28 Aug 2026 13:40:15 +0200 Subject: [PATCH 2/5] Feat: Review roles --- .github/copilot-instructions.md | 15 +- CLAUDE.md | 26 +- src/lib/components/AppSidebar.svelte | 238 ++++++++++++------ src/lib/components/RoleDetail.svelte | 35 +-- src/lib/components/RolePermissions.svelte | 30 ++- src/lib/components/Users.svelte | 130 ++++++---- .../auth/application/cancan.service.test.ts | 46 ++++ .../auth/application/cancan.service.ts | 22 +- src/routes/+layout.server.ts | 59 +++++ src/routes/+layout.svelte | 11 + .../[slug]/settings/overview/+page.server.ts | 8 +- .../[slug]/settings/overview/+page.svelte | 79 +++--- .../roles-permissions/+page.server.ts | 13 +- .../settings/roles-permissions/+page.svelte | 2 + .../roles-permissions/[id]/+page.server.ts | 18 +- .../roles-permissions/[id]/+page.svelte | 4 + .../roles-permissions/new/+page.server.ts | 9 +- .../roles-permissions/new/+page.svelte | 12 +- .../settings/users-groups/+page.server.ts | 16 +- .../[slug]/settings/users-groups/+page.svelte | 4 + .../[org]/settings/projects/+page.server.ts | 14 +- .../org/[org]/settings/projects/+page.svelte | 63 +++-- .../roles-permissions/+page.server.ts | 10 +- .../settings/roles-permissions/+page.svelte | 7 +- .../roles-permissions/[id]/+page.server.ts | 14 +- .../roles-permissions/[id]/+page.svelte | 9 +- .../roles-permissions/new/+page.server.ts | 8 +- .../roles-permissions/new/+page.svelte | 3 +- .../org/[org]/settings/users/+page.server.ts | 12 +- .../org/[org]/settings/users/+page.svelte | 4 +- 30 files changed, 680 insertions(+), 241 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6d992ee..8b61c86 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -50,17 +50,26 @@ Los permisos usan `section:action` con scope global, de organizacion o de proyec `can()` o `isAdmin()` desde `$modules/auth`; `locals.user.role` es un objeto, no el string `admin`. +**Gating de permisos en UI:** En loaders de rutas, usar `cancanService.canSessionUser()` para verificar +permisos específicos y pasarlos a componentes como props (`canCreate`, `canUpdate`, `canDelete`) para +ocultar acciones que el usuario no puede realizar. + Roles por defecto en `src/modules/auth/domain/role-permissions.data.ts`. Los permisos incluyen scope como prefijo (ej: `organization:projects:read`, `project:vault:secrets:all`) y se almacenan verbatim sin transformaciones: - **Cluster Admin**: vault, openreport, stateiac (todos) +- **Cluster User**: sin permisos propios; rol base para acceso a nivel cluster - **Organization Admin**: todos los permisos de org (proyectos, usuarios, roles, backups, audit) - **Organization Developer**: solo read/create/update de proyectos - **Project Admin/Developer/Viewer**: permisos granulares por modulo (vault, codereport, stateiac) -Los permisos de organización se propagan a sus proyectos: un usuario con `organization:projects:read` puede -satisfacer checks `project:project:read` en cualquier proyecto de esa organización, permitiendo delegacion -de autoridad sin crear grants por-proyecto. +Los permisos de organización se propagan a sus proyectos solo cuando no hay un assignment explícito a nivel de +proyecto. Un usuario con `organization:projects:read` puede satisfacer `project:project:read` en cualquier +proyecto—pero si tiene un rol específico del proyecto, ese assignment es autoritario y los permisos de org no +aplican (regla most-specific-wins). Permite delegación de autoridad granular con restricciones por-proyecto. + +`canManageOrganization()` controla acceso al area `/settings`, mientras que `canViewOrganization()` incluye +tambien usuarios con acceso solo a proyectos bajo la organización (ven el overview pero no pueden acciones de org-scope). Al crear una organizacion (via bootstrap o cluster-settings), se llama automaticamente a `roleService.createDefaultOrganizationRoles()`. Al crear un proyecto, se llama a diff --git a/CLAUDE.md b/CLAUDE.md index ff4fa9b..b48a41a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,19 +73,39 @@ if (!can(locals.user, 'stateiac:read')) { `locals.user.role` is a session role object, not the string `admin`. Use `isAdmin()` or `can()`. +**UI permission gating:** In route loaders, use `cancanService.canSessionUser()` to check specific +action permissions and pass them to components as props (`canCreate`, `canUpdate`, `canDelete`) to conditionally +render actions in the UI. This prevents users from seeing actions they cannot perform: + +```typescript +const canCreate = await cancanService.canSessionUser(locals.user, 'project:roles:create', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, +}); +return { roles, canCreate }; +``` + Default roles and permissions are centralized in `src/modules/auth/domain/role-permissions.data.ts`. Permissions always include their scope as a prefix (e.g., `organization:projects:read`, `project:vault:secrets:all`) and are stored verbatim—there is no scope-stripping transformation: - **Cluster Admin** (`vault:all`, `openreport:all`, `stateiac:all`) +- **Cluster User** (no inherent permissions; used as base role for cluster-level access) - **Organization Admin** (all org-level permissions: projects, users, roles, backups, audit) - **Organization Developer** (read/create/update projects only) - **Project Admin** (all project-level permissions across vault, codereport, stateiac) - **Project Developer** (read/create/update resources; no deletion/admin) - **Project Viewer** (read-only across all project modules) -Organization-level permissions cascade into their projects: a user with `organization:projects:read` can satisfy -a `project:project:read` check on any project in that organization. This allows coarse-grained org roles -to delegate authority downward without creating a separate per-project role grant. +Organization-level permissions cascade into their projects only when no explicit project-level assignment exists +for that user. A user with `organization:projects:read` can satisfy a `project:project:read` check on any project +in that organization—but if they have a project-specific role assignment, that assignment is authoritative and +organization permissions do not apply (most-specific-wins rule). This allows coarse-grained org roles to delegate +authority downward, while still permitting per-project restrictions. + +Two helpers distinguish organization visibility from management: `canManageOrganization()` gates the `/settings` area, +while `canViewOrganization()` also includes users whose only access is to a project under that organization +(they see the org overview, but cannot perform org-scope actions). When creating an organization (via bootstrap or cluster settings), `roleService.createDefaultOrganizationRoles()` is automatically invoked. When creating a project, `roleService.createDefaultProjectRoles()` is automatically invoked. Both diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte index e171cb3..97a136a 100644 --- a/src/lib/components/AppSidebar.svelte +++ b/src/lib/components/AppSidebar.svelte @@ -36,6 +36,17 @@ export let canAccessClusterSettings = false; export let canManageOrganization = false; export let canManageProject = false; + export let canReadOrgProjects = false; + export let canReadOrgUsers = false; + export let canReadOrgRoles = false; + export let canReadOrgBackups = false; + export let canReadOrgServerKeys = false; + export let canReadOrgAudit = false; + export let canReadProjectInfo = false; + export let canReadProjectUsers = false; + export let canReadProjectRoles = false; + export let canReadProjectServerKeys = false; + export let canReadProjectAudit = false; export let currentProjectSlug: string | null = null; export let projects: { slug: string; @@ -92,7 +103,13 @@ { name: $_('sidebar.modules.vault'), icon: Shield, - items: [{ label: $_('sidebar.modules.vault'), href: `${projectBase}/vault`, icon: Shield }], + items: [ + { + label: $_('sidebar.modules.vault'), + href: `${projectBase}/vault`, + icon: Shield, + }, + ], }, ], }, @@ -112,7 +129,11 @@ href: `${projectBase}/code-report/dashboard`, icon: LayoutDashboard, }, - { label: $_('sidebar.items.services'), href: `${projectBase}/code-report/services`, icon: Layers }, + { + label: $_('sidebar.items.services'), + href: `${projectBase}/code-report/services`, + icon: Layers, + }, { label: $_('sidebar.items.cves'), href: `/org/${currentProjectOrgSlug}/cves?project=${currentProjectSlug}`, @@ -123,8 +144,16 @@ href: `${projectBase}/code-report/security-policy`, icon: Shield, }, - { label: $_('sidebar.items.history'), href: `${projectBase}/code-report/history`, icon: GitBranch }, - { label: $_('sidebar.items.settings'), href: `${projectBase}/code-report/settings`, icon: Settings }, + { + label: $_('sidebar.items.history'), + href: `${projectBase}/code-report/history`, + icon: GitBranch, + }, + { + label: $_('sidebar.items.settings'), + href: `${projectBase}/code-report/settings`, + icon: Settings, + }, ], }, ], @@ -140,38 +169,10 @@ name: $_('sidebar.modules.stateIac'), icon: GitBranch, items: [ - { label: $_('sidebar.modules.stateIac'), href: `${projectBase}/state-iac`, icon: GitBranch }, - ], - }, - ], - }, - ] - : []), - ...(currentProjectSlug && canManageProject - ? [ - { - name: $_('sidebar.categories.proyecto'), - modules: [ - { - name: $_('sidebar.modules.projectSettings'), - icon: FolderKanban, - items: [ - { label: $_('sidebar.items.information'), href: `${projectBase}/settings/overview`, icon: Info }, - { - label: $_('sidebar.items.usersAndGroups'), - href: `${projectBase}/settings/users-groups`, - icon: Users, - }, { - label: $_('sidebar.items.rolesAndPermissions'), - href: `${projectBase}/settings/roles-permissions`, - icon: Shield, - }, - { label: $_('sidebar.items.audit'), href: `${projectBase}/settings/audit`, icon: ScrollText }, - { - label: $_('sidebar.items.serverKeys'), - href: `${projectBase}/settings/server-keys`, - icon: Shield, + label: $_('sidebar.modules.stateIac'), + href: `${projectBase}/state-iac`, + icon: GitBranch, }, ], }, @@ -179,74 +180,157 @@ }, ] : []), + ...(currentProjectSlug && canManageProject + ? (() => { + const items = [ + { + label: $_('sidebar.items.information'), + href: `${projectBase}/settings/overview`, + icon: Info, + visible: canReadProjectInfo, + }, + { + label: $_('sidebar.items.usersAndGroups'), + href: `${projectBase}/settings/users-groups`, + icon: Users, + visible: canReadProjectUsers, + }, + { + label: $_('sidebar.items.rolesAndPermissions'), + href: `${projectBase}/settings/roles-permissions`, + icon: Shield, + visible: canReadProjectRoles, + }, + { + label: $_('sidebar.items.audit'), + href: `${projectBase}/settings/audit`, + icon: ScrollText, + visible: canReadProjectAudit, + }, + { + label: $_('sidebar.items.serverKeys'), + href: `${projectBase}/settings/server-keys`, + icon: Shield, + visible: canReadProjectServerKeys, + }, + ] + .filter((item) => item.visible) + .map(({ visible, ...item }) => item); + + return items.length > 0 + ? [ + { + name: $_('sidebar.categories.proyecto'), + modules: [ + { + name: $_('sidebar.modules.projectSettings'), + icon: FolderKanban, + items, + }, + ], + }, + ] + : []; + })() + : []), { name: $_('sidebar.categories.sistema'), modules: [ ...(organizationSlug && canManageOrganization + ? (() => { + const items = [ + { + label: $_('sidebar.items.projects'), + href: `/org/${organizationSlug}/settings/projects`, + icon: FolderKanban, + visible: canReadOrgProjects, + }, + { + label: $_('sidebar.items.global'), + href: `/org/${organizationSlug}/settings/global`, + icon: Shield, + visible: true, + }, + { + label: $_('sidebar.items.users'), + href: `/org/${organizationSlug}/settings/users`, + icon: Users, + visible: canReadOrgUsers, + }, + { + label: $_('sidebar.items.rolesAndPermissions'), + href: `/org/${organizationSlug}/settings/roles-permissions`, + icon: Users, + visible: canReadOrgRoles, + }, + { + label: $_('sidebar.items.systemAndBackup'), + href: `/org/${organizationSlug}/settings/system-backup`, + icon: Database, + visible: canReadOrgBackups, + }, + { + label: $_('sidebar.items.serverAccessKeys'), + href: `/org/${organizationSlug}/settings/server-access-keys`, + icon: KeyRound, + visible: canReadOrgServerKeys, + }, + { + label: $_('sidebar.items.audit'), + href: `/org/${organizationSlug}/settings/audit`, + icon: ScrollText, + visible: canReadOrgAudit, + }, + ] + .filter((item) => item.visible) + .map(({ visible, ...item }) => item); + + return items.length > 0 + ? [ + { + name: $_('sidebar.modules.organizationSettings'), + icon: Settings, + items, + }, + ] + : []; + })() + : []), + ...(canAccessClusterSettings ? [ { - name: $_('sidebar.modules.organizationSettings'), - icon: Settings, + name: $_('sidebar.modules.clusterSettings'), + icon: Building2, items: [ { - label: $_('sidebar.items.projects'), - href: `/org/${organizationSlug}/settings/projects`, - icon: FolderKanban, + label: $_('sidebar.items.organizations'), + href: '/cluster-settings/orgs', + icon: Building2, }, { - label: $_('sidebar.items.global'), - href: `/org/${organizationSlug}/settings/global`, + label: $_('sidebar.items.rolesAndPermissions'), + href: '/cluster-settings/roles-permissions', icon: Shield, }, { label: $_('sidebar.items.users'), - href: `/org/${organizationSlug}/settings/users`, - icon: Users, - }, - { - label: $_('sidebar.items.rolesAndPermissions'), - href: `/org/${organizationSlug}/settings/roles-permissions`, + href: '/cluster-settings/users', icon: Users, }, { - label: $_('sidebar.items.systemAndBackup'), - href: `/org/${organizationSlug}/settings/system-backup`, + label: $_('sidebar.items.database'), + href: '/cluster-settings/database', icon: Database, }, { - label: $_('sidebar.items.serverAccessKeys'), - href: `/org/${organizationSlug}/settings/server-access-keys`, - icon: KeyRound, - }, - { - label: $_('sidebar.items.audit'), - href: `/org/${organizationSlug}/settings/audit`, + label: $_('sidebar.items.auditLog'), + href: '/cluster-settings/audit', icon: ScrollText, }, ], }, ] : []), - ...(canAccessClusterSettings - ? [ - { - name: $_('sidebar.modules.clusterSettings'), - icon: Building2, - items: [ - { label: $_('sidebar.items.organizations'), href: '/cluster-settings/orgs', icon: Building2 }, - { - label: $_('sidebar.items.rolesAndPermissions'), - href: '/cluster-settings/roles-permissions', - icon: Shield, - }, - { label: $_('sidebar.items.users'), href: '/cluster-settings/users', icon: Users }, - { label: $_('sidebar.items.database'), href: '/cluster-settings/database', icon: Database }, - { label: $_('sidebar.items.auditLog'), href: '/cluster-settings/audit', icon: ScrollText }, - ], - }, - ] - : []), - ], }, ].filter((category) => category.modules.length > 0) satisfies NavCategory[]; diff --git a/src/lib/components/RoleDetail.svelte b/src/lib/components/RoleDetail.svelte index 49c3e98..4299588 100644 --- a/src/lib/components/RoleDetail.svelte +++ b/src/lib/components/RoleDetail.svelte @@ -10,6 +10,9 @@ export let role: RoleRow | null = null; export let cancelHref = './'; export let title = ''; + export let canCreate = true; + export let canUpdate = true; + export let canDelete = true; type RoleRow = { id: string; @@ -44,6 +47,7 @@ const visibleActions = defaultActions.filter((action) => action !== 'all'); const isCreate = !role; + $: canSave = isCreate ? canCreate : canUpdate; let roleName = role?.name ?? ''; let roleSlug = role?.slug ?? ''; let selectedPermissions = toUiPermissions(role?.permissions ?? []); @@ -437,7 +441,8 @@ id="role-name" type="text" bind:value={roleName} - class="mt-1 w-full rounded-md border border-transparent bg-transparent px-0 py-1 text-slate-200 outline-none transition placeholder:text-slate-600 focus:border-[#dfff22] focus:bg-[#24262b] focus:px-2" + readonly={!canSave} + class="mt-1 w-full rounded-md border border-transparent bg-transparent px-0 py-1 text-slate-200 outline-none transition placeholder:text-slate-600 read-only:text-slate-300 focus:border-[#dfff22] focus:bg-[#24262b] focus:px-2" placeholder={$_('roleDetail.developerPlaceholder')} /> @@ -450,7 +455,7 @@ id="role-slug" type="text" bind:value={roleSlug} - readonly={!isCreate} + readonly={!isCreate || !canSave} class="mt-1 w-full rounded-md border border-transparent bg-transparent px-0 py-1 text-slate-200 outline-none transition placeholder:text-slate-600 read-only:text-slate-300 focus:border-[#dfff22] focus:bg-[#24262b] focus:px-2" placeholder="developer" /> @@ -471,7 +476,7 @@

{$_('roleDetail.permissions')}

- - - {$_('rolePermissions.addRole')} - + {#if canCreate} + + + {$_('rolePermissions.addRole')} + + {/if} {#if roles.length === 0} -
+
{$_('rolePermissions.empty')}
{:else}
- + @@ -61,7 +68,10 @@ {#each roles as role (role.id)} @@ -494,7 +514,9 @@ {#if scope !== 'project'}
- +
- +
- + {:else}
- +
-
{$_('usersComponent.inviteModalTitle')}
+
+ {$_('usersComponent.inviteModalTitle')} +
{#if inviteError} @@ -603,7 +635,9 @@ {$_('usersComponent.inviteModalDescription')}

- +
-
{$_('usersComponent.removeModalTitle')}
+
+ {$_('usersComponent.removeModalTitle')} +

- {$_('usersComponent.removeModalDescriptionStart')} {removeModalUser.username}{$_('usersComponent.removeModalDescriptionEnd')} + {$_('usersComponent.removeModalDescriptionStart')} + {removeModalUser.username}{$_( + 'usersComponent.removeModalDescriptionEnd', + )}

@@ -664,7 +701,8 @@ type="button" on:click={() => (removeModalUser = null)} class="btn-secondary rounded-md px-3 py-2 text-sm font-medium" - >{$_('common.cancel')} + >{$_('common.cancel')}
diff --git a/src/modules/auth/application/cancan.service.test.ts b/src/modules/auth/application/cancan.service.test.ts index 405d420..e1640bc 100644 --- a/src/modules/auth/application/cancan.service.test.ts +++ b/src/modules/auth/application/cancan.service.test.ts @@ -297,6 +297,52 @@ describe('CanCanService', () => { service.can('jose', 'project:project:read', { scope: 'project', projectId: 'kettu' }), ).resolves.toBe(false); }); + + it('a project-specific assignment wins over broader organization access (most-specific-wins)', async () => { + userRepository.rows.set('jose', user({ id: 'jose', role: null })); + userAccessRepository.rows.push( + // full authority over the organization's projects... + access({ + id: 'access-org', + userId: 'jose', + scope: 'organization', + organizationId: 'gitops', + role: role({ + id: 'org-full-access', + slug: 'org-full-access', + scope: 'organization', + permissions: ['organization:projects:all'], + }), + }), + // ...but explicitly only a viewer on this one project + access({ + id: 'access-project', + userId: 'jose', + scope: 'project', + projectId: 'kettu', + project: { id: 'kettu', organizationId: 'gitops' }, + role: role({ + id: 'project-viewer', + slug: 'project-viewer', + scope: 'project', + permissions: ['project:project:read'], + }), + }), + ); + + // the explicit project-viewer assignment is authoritative: read passes... + await expect( + service.can('jose', 'project:project:read', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(true); + // ...but the organization's organization:projects:all must NOT leak through and grant + // write access the project-level role doesn't have. + await expect( + service.can('jose', 'project:project:update', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(false); + await expect( + service.can('jose', 'project:project:delete', { scope: 'project', projectId: 'kettu' }), + ).resolves.toBe(false); + }); }); describe('canManageOrganization / canViewOrganization', () => { diff --git a/src/modules/auth/application/cancan.service.ts b/src/modules/auth/application/cancan.service.ts index 73a6034..1804033 100644 --- a/src/modules/auth/application/cancan.service.ts +++ b/src/modules/auth/application/cancan.service.ts @@ -180,17 +180,27 @@ export class CanCanService { if (context.scope !== 'project') return []; + // most-specific-wins: an explicit project-level assignment for this exact project is + // authoritative on its own — it does not get supplemented (or overridden) by whatever the + // user's organization role would otherwise allow. The organization role only cascades down + // when the user has no project-level assignment here at all. + const projectEntries = access.filter( + (entry) => entry.scope === 'project' && entry.projectId === context.projectId, + ); + + if (projectEntries.length > 0) { + return projectEntries + .filter((entry): entry is UserAccessDomain & { role: RoleDomain } => Boolean(entry.role)) + .map((entry) => ({ role: entry.role, scope: 'project' as const })); + } + const organizationId = context.organizationId ?? (await this.findProjectOrganizationId(context.projectId)); return access - .filter((entry) => { - if (entry.scope === 'project') return entry.projectId === context.projectId; - if (entry.scope === 'organization') return entry.organizationId === organizationId; - return false; - }) + .filter((entry) => entry.scope === 'organization' && entry.organizationId === organizationId) .filter((entry): entry is UserAccessDomain & { role: RoleDomain } => Boolean(entry.role)) - .map((entry) => ({ role: entry.role, scope: entry.scope as 'organization' | 'project' })); + .map((entry) => ({ role: entry.role, scope: 'organization' as const })); } private roleCan(role: PermissionRole, permission: PermissionGrant): boolean { diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index cd7318e..51664ea 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -35,6 +35,31 @@ export async function load({ locals, url }) { const canManageOrganization = organization ? await cancanService.canManageOrganization(locals.user, organization.id) : false; + + const orgSectionPermission = (permission: string) => + organization + ? cancanService.canSessionUser(locals.user, permission, { + scope: 'organization', + organizationId: organization.id, + }) + : Promise.resolve(false); + + const [ + canReadOrgProjects, + canReadOrgUsers, + canReadOrgRoles, + canReadOrgBackups, + canReadOrgServerKeys, + canReadOrgAudit, + ] = await Promise.all([ + orgSectionPermission('organization:projects:read'), + orgSectionPermission('organization:users:read'), + orgSectionPermission('organization:roles:read'), + orgSectionPermission('organization:backups:read'), + orgSectionPermission('organization:server-keys:read'), + orgSectionPermission('organization:audit:read'), + ]); + const currentProjectSlug = url.pathname.match(/\/projects\/([^/]+)/)?.[1] ?? null; const projects = locals.user @@ -68,6 +93,29 @@ export async function load({ locals, url }) { ) : false; + const projectSectionPermission = (permission: string) => + currentProject + ? cancanService.canSessionUser(locals.user, permission, { + scope: 'project', + projectId: currentProject.id, + organizationId: currentProject.organization?.id, + }) + : Promise.resolve(false); + + const [ + canReadProjectInfo, + canReadProjectUsers, + canReadProjectRoles, + canReadProjectServerKeys, + canReadProjectAudit, + ] = await Promise.all([ + projectSectionPermission('project:project:read'), + projectSectionPermission('project:users:read'), + projectSectionPermission('project:roles:read'), + projectSectionPermission('project:server-keys:read'), + projectSectionPermission('project:audit:read'), + ]); + return { // isConfigured: !!config && backends.length > 0, isConfigured: true, @@ -79,6 +127,17 @@ export async function load({ locals, url }) { canAccessClusterSettings, canManageOrganization, canManageProject, + canReadOrgProjects, + canReadOrgUsers, + canReadOrgRoles, + canReadOrgBackups, + canReadOrgServerKeys, + canReadOrgAudit, + canReadProjectInfo, + canReadProjectUsers, + canReadProjectRoles, + canReadProjectServerKeys, + canReadProjectAudit, currentProjectSlug, }; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 342a9e2..5a84035 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -54,6 +54,17 @@ canAccessClusterSettings={data.canAccessClusterSettings} canManageOrganization={data.canManageOrganization} canManageProject={data.canManageProject} + canReadOrgProjects={data.canReadOrgProjects} + canReadOrgUsers={data.canReadOrgUsers} + canReadOrgRoles={data.canReadOrgRoles} + canReadOrgBackups={data.canReadOrgBackups} + canReadOrgServerKeys={data.canReadOrgServerKeys} + canReadOrgAudit={data.canReadOrgAudit} + canReadProjectInfo={data.canReadProjectInfo} + canReadProjectUsers={data.canReadProjectUsers} + canReadProjectRoles={data.canReadProjectRoles} + canReadProjectServerKeys={data.canReadProjectServerKeys} + canReadProjectAudit={data.canReadProjectAudit} currentProjectSlug={data.currentProjectSlug ?? null} bind:collapsed={sidebarCollapsed} /> diff --git a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts index 81af0cf..85a1afe 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts @@ -22,7 +22,13 @@ export async function load({ params, locals }) { if (!canRead) throw error(403, 'Forbidden'); - return { project }; + const canDelete = await cancanService.canSessionUser(locals.user, 'project:project:delete', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + return { project, canDelete }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte index 3d666de..69f4fae 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte @@ -36,7 +36,7 @@ updatedAt: string; }; - export let data: { project: ProjectRow }; + export let data: { project: ProjectRow; canDelete: boolean }; $: project = data.project; $: orgSlug = $page?.params?.org ?? ''; @@ -81,7 +81,9 @@ $: integrationCards = [ { label: 'GitOps CLI', - description: $_('projectSettings.overview.gitopsCliDescription', { values: { slug: data.project.slug } }), + description: $_('projectSettings.overview.gitopsCliDescription', { + values: { slug: data.project.slug }, + }), icon: Terminal, }, { @@ -194,8 +196,9 @@ await update(); if (result.type === 'failure') { - error = - result.data?.error ? String(result.data.error) : $_('projectSettings.overview.deleteFailed'); + error = result.data?.error + ? String(result.data.error) + : $_('projectSettings.overview.deleteFailed'); deleteLoading = false; } }; @@ -292,8 +295,7 @@ bind:value={editDescription} rows="4" class="field-input mt-2 w-full rounded-md border px-3 py-2 text-sm outline-none transition" - placeholder={$_('projectSettings.overview.optionalDescription')} - > + placeholder={$_('projectSettings.overview.optionalDescription')}>
@@ -372,7 +374,9 @@
-

{$_('projectSettings.overview.integrations')}

+

+ {$_('projectSettings.overview.integrations')} +

{$_('projectSettings.overview.integrationsDescription')}

@@ -406,7 +410,9 @@
-

{$_('projectSettings.overview.dangerZone')}

+

+ {$_('projectSettings.overview.dangerZone')} +

@@ -458,24 +464,26 @@
-
-
-

- {$_('projectSettings.overview.deleteProject')} -

-

- {$_('projectSettings.overview.deleteDescription')} -

+ {#if data.canDelete} +
+
+

+ {$_('projectSettings.overview.deleteProject')} +

+

+ {$_('projectSettings.overview.deleteDescription')} +

+
+
- -
+ {/if}
@@ -508,13 +516,15 @@
{#if isArchived} - {$_('projectSettings.overview.activateConfirmationStart')} {project.name}{$_('projectSettings.overview.activateConfirmationEnd')} + {$_('projectSettings.overview.activateConfirmationStart')} + {project.name}{$_( + 'projectSettings.overview.activateConfirmationEnd', + )} {:else} - {$_('projectSettings.overview.archiveConfirmationStart')} {project.name}{$_('projectSettings.overview.archiveConfirmationEnd')} + {$_('projectSettings.overview.archiveConfirmationStart')} + {project.name}{$_( + 'projectSettings.overview.archiveConfirmationEnd', + )} {/if}
@@ -572,9 +582,10 @@
- {$_('projectSettings.overview.deleteConfirmationStart')} {project.name}{$_('projectSettings.overview.deleteConfirmationEnd')} + {$_('projectSettings.overview.deleteConfirmationStart')} + {project.name}{$_( + 'projectSettings.overview.deleteConfirmationEnd', + )}
diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts index 648fa19..59800bd 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts @@ -27,10 +27,17 @@ async function canManageProjectRole( return { project, allowed }; } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { project } = await parent(); - const roles = await roleService.listRoles('project', project.id); - return { roles }; + const [roles, canCreate] = await Promise.all([ + roleService.listRoles('project', project.id), + cancanService.canSessionUser(locals.user, 'project:roles:create', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + ]); + return { roles, canCreate }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.svelte index 6871b8d..8331036 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.svelte @@ -5,6 +5,7 @@ export let data: { project: { slug: string; organization: { slug: string } | null }; roles: any[]; + canCreate: boolean; }; $: baseHref = `/org/${data.project.organization?.slug ?? ''}/projects/${data.project.slug}/settings/roles-permissions`; @@ -15,4 +16,5 @@ title={$_('projectSettings.roles.title')} description={$_('projectSettings.roles.description')} {baseHref} + canCreate={data.canCreate} /> diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts index b6f8bbc..53e2685 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts @@ -29,12 +29,24 @@ async function canManageProjectRole( return { project, allowed }; } -export async function load({ parent, params }) { +export async function load({ parent, params, locals }) { const { project } = await parent(); - const roles = await roleService.listRoles('project', project.id); + const [roles, canUpdate, canDelete] = await Promise.all([ + roleService.listRoles('project', project.id), + cancanService.canSessionUser(locals.user, 'project:roles:update', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:roles:delete', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + ]); const role = roles.find((row) => row.id === params.id); if (!role) throw error(404, 'Role not found'); - return { project, role }; + return { project, role, canUpdate, canDelete }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.svelte index 27e2e8c..a795cac 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.svelte @@ -5,6 +5,8 @@ export let data: { project: { slug: string; organization: { slug: string } | null }; role: any; + canUpdate: boolean; + canDelete: boolean; }; $: baseHref = `/org/${data.project.organization?.slug ?? ''}/projects/${data.project.slug}/settings/roles-permissions`; @@ -15,4 +17,6 @@ role={data.role} title={`${$_('roleDetail.edit')} ${data.role.name}`} cancelHref={baseHref} + canUpdate={data.canUpdate} + canDelete={data.canDelete} /> diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts index a9ae860..0099790 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts @@ -29,9 +29,14 @@ async function canManageProjectRole( return { project, allowed }; } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { project } = await parent(); - return { project }; + const canCreate = await cancanService.canSessionUser(locals.user, 'project:roles:create', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + return { project, canCreate }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.svelte index 06fee15..6d760cb 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.svelte @@ -2,9 +2,17 @@ import RoleDetail from '$lib/components/RoleDetail.svelte'; import { _ } from 'svelte-i18n'; - export let data: { project: { slug: string; organization: { slug: string } | null } }; + export let data: { + project: { slug: string; organization: { slug: string } | null }; + canCreate: boolean; + }; $: baseHref = `/org/${data.project.organization?.slug ?? ''}/projects/${data.project.slug}/settings/roles-permissions`; - + diff --git a/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.server.ts index 4a6eee2..f3e5f67 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.server.ts @@ -25,14 +25,24 @@ async function canUpdateProjectUsers( return { project, allowed }; } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { project } = await parent(); - const [users, roles, assignableUsers] = await Promise.all([ + const [users, roles, assignableUsers, canCreate, canDelete] = await Promise.all([ userAccessService.listUsers('project', project.id), roleService.listRoles('project', project.id), userAccessService.listAssignableUsers(), + cancanService.canSessionUser(locals.user, 'project:users:create', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:users:delete', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), ]); - return { project, users, roles, assignableUsers }; + return { project, users, roles, assignableUsers, canCreate, canDelete }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.svelte index e004888..bb3004f 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/users-groups/+page.svelte @@ -7,6 +7,8 @@ roles: any[]; assignableUsers: any[]; project: { name: string }; + canCreate: boolean; + canDelete: boolean; }; @@ -17,4 +19,6 @@ assignableUsers={data.assignableUsers} title={$_('projectSettings.users.title')} description={`${$_('projectSettings.users.descriptionPrefix')} ${data.project.name}${$_('projectSettings.users.descriptionSuffix')}`} + canCreate={data.canCreate} + canDelete={data.canDelete} /> diff --git a/src/routes/org/[org]/settings/projects/+page.server.ts b/src/routes/org/[org]/settings/projects/+page.server.ts index 7a4a9c9..e5ea6da 100644 --- a/src/routes/org/[org]/settings/projects/+page.server.ts +++ b/src/routes/org/[org]/settings/projects/+page.server.ts @@ -19,8 +19,18 @@ export async function load({ params, locals }) { throw error(403, 'Forbidden'); } - const projects = await projectService.listProjectsByOrganization(organization.id); - return { organization, projects }; + const [projects, canCreate, canDelete] = await Promise.all([ + projectService.listProjectsByOrganization(organization.id), + cancanService.canSessionUser(locals.user, 'organization:projects:create', { + scope: 'organization', + organizationId: organization.id, + }), + cancanService.canSessionUser(locals.user, 'organization:projects:delete', { + scope: 'organization', + organizationId: organization.id, + }), + ]); + return { organization, projects, canCreate, canDelete }; } export const actions = { diff --git a/src/routes/org/[org]/settings/projects/+page.svelte b/src/routes/org/[org]/settings/projects/+page.svelte index c1457a7..f550895 100644 --- a/src/routes/org/[org]/settings/projects/+page.svelte +++ b/src/routes/org/[org]/settings/projects/+page.svelte @@ -4,7 +4,12 @@ import { CheckCircle, Eye, FolderKanban, Plus, Search, Trash2 } from '@lucide/svelte'; import { _ } from 'svelte-i18n'; - export let data: { organization: { id: string; slug: string } | null; projects: ProjectRow[] }; + export let data: { + organization: { id: string; slug: string } | null; + projects: ProjectRow[]; + canCreate: boolean; + canDelete: boolean; + }; type ProjectRow = { id: string; @@ -155,12 +160,15 @@

{$_('orgSettings.projects.description')}

- + {#if data.canCreate} + + {/if}
@@ -204,7 +212,9 @@
- {projects.length === 0 ? $_('orgSettings.projects.empty') : $_('orgSettings.projects.emptyFiltered')} + {projects.length === 0 + ? $_('orgSettings.projects.empty') + : $_('orgSettings.projects.emptyFiltered')}
{:else}
@@ -256,14 +266,18 @@ class="btn-secondary inline-flex items-center gap-1.5 rounded-md px-2.5 py-2 text-xs font-medium" title={$_('orgSettings.projects.viewProject')} > - {$_('common.view')} - + {#if data.canDelete} + + {/if}
@@ -290,7 +304,9 @@ aria-label={$_('orgSettings.projects.createModal')} >
-
{$_('orgSettings.projects.createModalTitle')}
+
+ {$_('orgSettings.projects.createModalTitle')} +
@@ -331,15 +347,16 @@
- + + placeholder={$_('orgSettings.projects.descriptionPlaceholder')}>
@@ -395,7 +412,9 @@ aria-label={$_('orgSettings.projects.deleteModal')} >
-
{$_('orgSettings.projects.deleteConfirmTitle')}
+
+ {$_('orgSettings.projects.deleteConfirmTitle')} +
@@ -428,7 +447,9 @@ class="btn-danger inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium" > - {deleteLoading ? $_('orgSettings.projects.deleting') : $_('orgSettings.projects.deleteProject')} + {deleteLoading + ? $_('orgSettings.projects.deleting') + : $_('orgSettings.projects.deleteProject')}
diff --git a/src/routes/org/[org]/settings/roles-permissions/+page.server.ts b/src/routes/org/[org]/settings/roles-permissions/+page.server.ts index dfe4930..2d88952 100644 --- a/src/routes/org/[org]/settings/roles-permissions/+page.server.ts +++ b/src/routes/org/[org]/settings/roles-permissions/+page.server.ts @@ -25,8 +25,14 @@ export async function load({ parent, locals }) { throw error(403, 'Forbidden'); } - const roles = await roleService.listRoles('organization', organization.id); - return { roles }; + const [roles, canCreate] = await Promise.all([ + roleService.listRoles('organization', organization.id), + cancanService.canSessionUser(locals.user, 'organization:roles:create', { + scope: 'organization', + organizationId: organization.id, + }), + ]); + return { roles, canCreate }; } export const actions = { diff --git a/src/routes/org/[org]/settings/roles-permissions/+page.svelte b/src/routes/org/[org]/settings/roles-permissions/+page.svelte index ad31f7b..ec94cad 100644 --- a/src/routes/org/[org]/settings/roles-permissions/+page.svelte +++ b/src/routes/org/[org]/settings/roles-permissions/+page.svelte @@ -2,7 +2,11 @@ import RolePermissions from '$lib/components/RolePermissions.svelte'; import { _ } from 'svelte-i18n'; - export let data: { organization: { id: string; slug: string }; roles: any[] }; + export let data: { + organization: { id: string; slug: string }; + roles: any[]; + canCreate: boolean; + }; diff --git a/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts b/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts index f0f7056..940cbf9 100644 --- a/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts +++ b/src/routes/org/[org]/settings/roles-permissions/[id]/+page.server.ts @@ -27,10 +27,20 @@ export async function load({ parent, params, locals }) { throw error(403, 'Forbidden'); } - const roles = await roleService.listRoles('organization', organization.id); + const [roles, canUpdate, canDelete] = await Promise.all([ + roleService.listRoles('organization', organization.id), + cancanService.canSessionUser(locals.user, 'organization:roles:update', { + scope: 'organization', + organizationId: organization.id, + }), + cancanService.canSessionUser(locals.user, 'organization:roles:delete', { + scope: 'organization', + organizationId: organization.id, + }), + ]); const role = roles.find((row) => row.id === params.id); if (!role) throw error(404, 'Role not found'); - return { organization, role }; + return { organization, role, canUpdate, canDelete }; } export const actions = { diff --git a/src/routes/org/[org]/settings/roles-permissions/[id]/+page.svelte b/src/routes/org/[org]/settings/roles-permissions/[id]/+page.svelte index 0fab8c8..5496f1b 100644 --- a/src/routes/org/[org]/settings/roles-permissions/[id]/+page.svelte +++ b/src/routes/org/[org]/settings/roles-permissions/[id]/+page.svelte @@ -2,7 +2,12 @@ import RoleDetail from '$lib/components/RoleDetail.svelte'; import { _ } from 'svelte-i18n'; - export let data: { organization: { slug: string }; role: any }; + export let data: { + organization: { slug: string }; + role: any; + canUpdate: boolean; + canDelete: boolean; + }; diff --git a/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts b/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts index 0da5ed5..a1bb942 100644 --- a/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts +++ b/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts @@ -15,9 +15,13 @@ function errorResponse(errorValue: unknown) { }); } -export async function load({ parent }) { +export async function load({ parent, locals }) { const { organization } = await parent(); - return { organization }; + const canCreate = await cancanService.canSessionUser(locals.user, 'organization:roles:create', { + scope: 'organization', + organizationId: organization.id, + }); + return { organization, canCreate }; } export const actions = { diff --git a/src/routes/org/[org]/settings/roles-permissions/new/+page.svelte b/src/routes/org/[org]/settings/roles-permissions/new/+page.svelte index 66335a1..c030a6a 100644 --- a/src/routes/org/[org]/settings/roles-permissions/new/+page.svelte +++ b/src/routes/org/[org]/settings/roles-permissions/new/+page.svelte @@ -2,11 +2,12 @@ import RoleDetail from '$lib/components/RoleDetail.svelte'; import { _ } from 'svelte-i18n'; - export let data: { organization: { slug: string } }; + export let data: { organization: { slug: string }; canCreate: boolean }; diff --git a/src/routes/org/[org]/settings/users/+page.server.ts b/src/routes/org/[org]/settings/users/+page.server.ts index 20cc850..1d09b39 100644 --- a/src/routes/org/[org]/settings/users/+page.server.ts +++ b/src/routes/org/[org]/settings/users/+page.server.ts @@ -18,11 +18,19 @@ export async function load({ parent, locals }) { throw error(403, 'Forbidden'); } - const [users, roles] = await Promise.all([ + const [users, roles, canCreate, canDelete] = await Promise.all([ userAccessService.listUsers('organization', organization.id), roleService.listRoles('organization', organization.id), + cancanService.canSessionUser(locals.user, 'organization:users:create', { + scope: 'organization', + organizationId: organization.id, + }), + cancanService.canSessionUser(locals.user, 'organization:users:delete', { + scope: 'organization', + organizationId: organization.id, + }), ]); - return { users, roles }; + return { users, roles, canCreate, canDelete }; } export const actions = { diff --git a/src/routes/org/[org]/settings/users/+page.svelte b/src/routes/org/[org]/settings/users/+page.svelte index 208926d..261d97f 100644 --- a/src/routes/org/[org]/settings/users/+page.svelte +++ b/src/routes/org/[org]/settings/users/+page.svelte @@ -2,7 +2,7 @@ import Users from '$lib/components/Users.svelte'; import { _ } from 'svelte-i18n'; - export let data: { users: any[]; roles: any[] }; + export let data: { users: any[]; roles: any[]; canCreate: boolean; canDelete: boolean }; From cdaeac8f5309da625418e621a1f00afdc67cf7f9 Mon Sep 17 00:00:00 2001 From: "Liam Verdejo (ES)" Date: Fri, 28 Aug 2026 14:45:10 +0200 Subject: [PATCH 3/5] Feat: Review roles --- .github/copilot-instructions.md | 4 +- CLAUDE.md | 7 + src/lib/components/AppSidebar.svelte | 194 ++++++++++-------- src/lib/components/RoleDetail.svelte | 2 +- .../auth/application/cancan.service.ts | 4 - 5 files changed, 122 insertions(+), 89 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8b61c86..c61a558 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -52,7 +52,9 @@ Los permisos usan `section:action` con scope global, de organizacion o de proyec **Gating de permisos en UI:** En loaders de rutas, usar `cancanService.canSessionUser()` para verificar permisos específicos y pasarlos a componentes como props (`canCreate`, `canUpdate`, `canDelete`) para -ocultar acciones que el usuario no puede realizar. +ocultar acciones que el usuario no puede realizar. El layout raíz (`+layout.server.ts`) calcula permisos +de lectura granulares para cada sección de settings (proyectos, usuarios, roles, backups, servidor-keys, +audit) y los filtra en AppSidebar según si el usuario tiene acceso a esa sección específica. Roles por defecto en `src/modules/auth/domain/role-permissions.data.ts`. Los permisos incluyen scope como prefijo (ej: `organization:projects:read`, `project:vault:secrets:all`) y se almacenan verbatim sin diff --git a/CLAUDE.md b/CLAUDE.md index 71213d0..155f847 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,13 @@ const canCreate = await cancanService.canSessionUser(locals.user, 'project:roles return { roles, canCreate }; ``` +The root layout (`+layout.server.ts`) calculates granular read permissions for each settings section and +passes them to the AppSidebar component, which filters sidebar items based on specific resource permissions: +- **Project level:** `project:project:read`, `project:users:read`, `project:roles:read`, `project:server-keys:read`, `project:audit:read` +- **Organization level:** `organization:projects:read`, `organization:users:read`, `organization:roles:read`, `organization:backups:read`, `organization:server-keys:read`, `organization:audit:read` + +This ensures the UI only displays navigation items for sections the user has permission to view. + Default roles and permissions are centralized in `src/modules/auth/domain/role-permissions.data.ts`. Permissions always include their scope as a prefix (e.g., `organization:projects:read`, `project:vault:secrets:all`) and are stored verbatim—there is no scope-stripping transformation: diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte index d55be67..81b30ca 100644 --- a/src/lib/components/AppSidebar.svelte +++ b/src/lib/components/AppSidebar.svelte @@ -185,94 +185,122 @@ ] : []), ...(currentProjectSlug && canManageProject - ? [ - { - name: $_('sidebar.categories.proyecto'), - modules: [ - { - name: $_('sidebar.modules.projectSettings'), - icon: FolderKanban, - base: `${projectBase}/settings`, - items: [ - { - label: $_('sidebar.items.information'), - href: `${projectBase}/settings/overview`, - icon: Info, - }, - { - label: $_('sidebar.items.accessControl'), - href: `${projectBase}/settings/access-control`, - icon: Users, - }, - { - label: $_('sidebar.items.rolesAndPermissions'), - href: `${projectBase}/settings/roles-permissions`, - icon: Shield, - }, - { - label: $_('sidebar.items.audit'), - href: `${projectBase}/settings/audit`, - icon: ScrollText, - }, - { - label: $_('sidebar.items.serverKeys'), - href: `${projectBase}/settings/server-access-keys`, - icon: Shield, - }, - ], - }, - ], - }, - ] + ? (() => { + const items = [ + { + label: $_('sidebar.items.information'), + href: `${projectBase}/settings/overview`, + icon: Info, + visible: canReadProjectInfo, + }, + { + label: $_('sidebar.items.accessControl'), + href: `${projectBase}/settings/access-control`, + icon: Users, + visible: canReadProjectUsers, + }, + { + label: $_('sidebar.items.rolesAndPermissions'), + href: `${projectBase}/settings/roles-permissions`, + icon: Shield, + visible: canReadProjectRoles, + }, + { + label: $_('sidebar.items.audit'), + href: `${projectBase}/settings/audit`, + icon: ScrollText, + visible: canReadProjectAudit, + }, + { + label: $_('sidebar.items.serverKeys'), + href: `${projectBase}/settings/server-access-keys`, + icon: Shield, + visible: canReadProjectServerKeys, + }, + ] + .filter((item) => item.visible) + .map(({ visible, ...item }) => item); + + return items.length > 0 + ? [ + { + name: $_('sidebar.categories.proyecto'), + modules: [ + { + name: $_('sidebar.modules.projectSettings'), + icon: FolderKanban, + base: `${projectBase}/settings`, + items, + }, + ], + }, + ] + : []; + })() : []), { name: $_('sidebar.categories.sistema'), modules: [ ...(organizationSlug && canManageOrganization - ? [ - { - name: $_('sidebar.modules.organizationSettings'), - icon: Settings, - base: `/org/${organizationSlug}/settings`, - items: [ - { - label: $_('sidebar.items.projects'), - href: `/org/${organizationSlug}/settings/projects`, - icon: FolderKanban, - }, - { - label: $_('sidebar.items.global'), - href: `/org/${organizationSlug}/settings/global`, - icon: Shield, - }, - { - label: $_('sidebar.items.accessControl'), - href: `/org/${organizationSlug}/settings/access-control`, - icon: Users, - }, - { - label: $_('sidebar.items.rolesAndPermissions'), - href: `/org/${organizationSlug}/settings/roles-permissions`, - icon: Users, - }, - { - label: $_('sidebar.items.systemAndBackup'), - href: `/org/${organizationSlug}/settings/system-backup`, - icon: Database, - }, - { - label: $_('sidebar.items.serverAccessKeys'), - href: `/org/${organizationSlug}/settings/server-access-keys`, - icon: KeyRound, - }, - { - label: $_('sidebar.items.audit'), - href: `/org/${organizationSlug}/settings/audit`, - icon: ScrollText, - }, - ], - }, - ] + ? (() => { + const items = [ + { + label: $_('sidebar.items.projects'), + href: `/org/${organizationSlug}/settings/projects`, + icon: FolderKanban, + visible: canReadOrgProjects, + }, + { + label: $_('sidebar.items.global'), + href: `/org/${organizationSlug}/settings/global`, + icon: Shield, + visible: true, + }, + { + label: $_('sidebar.items.accessControl'), + href: `/org/${organizationSlug}/settings/access-control`, + icon: Users, + visible: canReadOrgUsers, + }, + { + label: $_('sidebar.items.rolesAndPermissions'), + href: `/org/${organizationSlug}/settings/roles-permissions`, + icon: Users, + visible: canReadOrgRoles, + }, + { + label: $_('sidebar.items.systemAndBackup'), + href: `/org/${organizationSlug}/settings/system-backup`, + icon: Database, + visible: canReadOrgBackups, + }, + { + label: $_('sidebar.items.serverAccessKeys'), + href: `/org/${organizationSlug}/settings/server-access-keys`, + icon: KeyRound, + visible: canReadOrgServerKeys, + }, + { + label: $_('sidebar.items.audit'), + href: `/org/${organizationSlug}/settings/audit`, + icon: ScrollText, + visible: canReadOrgAudit, + }, + ] + .filter((item) => item.visible) + .map(({ visible, ...item }) => item); + + return items.length > 0 + ? [ + { + name: $_('sidebar.modules.organizationSettings'), + icon: Settings, + base: `/org/${organizationSlug}/settings`, + items, + }, + ] + : []; + })() : []), ...(canAccessClusterSettings ? [ diff --git a/src/lib/components/RoleDetail.svelte b/src/lib/components/RoleDetail.svelte index 26ad312..87af41e 100644 --- a/src/lib/components/RoleDetail.svelte +++ b/src/lib/components/RoleDetail.svelte @@ -3,7 +3,7 @@ import { goto, invalidateAll } from '$app/navigation'; import { CheckCircle, ChevronDown, ChevronRight, Pencil, Save, Trash2, X } from '@lucide/svelte'; import permissionsCatalog, { defaultActions } from '$lib/config/permissions'; - import { normalizePermissionGrant } from '$lib/permissions'; + import { normalizePermissionGrant, toStoredPermissionGrant } from '$lib/permissions'; import { _ } from 'svelte-i18n'; export let scope: 'cluster' | 'organization' | 'project'; diff --git a/src/modules/auth/application/cancan.service.ts b/src/modules/auth/application/cancan.service.ts index adf30dc..01c609a 100644 --- a/src/modules/auth/application/cancan.service.ts +++ b/src/modules/auth/application/cancan.service.ts @@ -89,10 +89,6 @@ export class CanCanService { return access.some( (entry) => entry.scope === 'project' && entry.project?.organization?.id === organizationId, ); - return this.canSessionUser(user, 'organization:projects:read', { - scope: 'organization', - organizationId, - }); } /** From c2f80da2a3bbe89d12d992e492ee679b0ee53f2a Mon Sep 17 00:00:00 2001 From: "Liam Verdejo (ES)" Date: Mon, 31 Aug 2026 13:53:57 +0200 Subject: [PATCH 4/5] Feat: Review roles & fixing comments --- .github/copilot-instructions.md | 12 +- CLAUDE.md | 14 +- src/lib/components/AppSidebar.svelte | 12 +- src/lib/config/permissions.ts | 4 + .../auth/domain/role-permissions.data.ts | 1 + src/routes/+layout.server.ts | 30 +++ src/routes/+layout.svelte | 4 + .../code-report/settings/+page.server.ts | 50 +++- .../[slug]/code-report/settings/+page.svelte | 23 +- .../[slug]/settings/overview/+page.server.ts | 21 +- .../[slug]/settings/overview/+page.svelte | 249 ++++++++++-------- .../server-access-keys/+page.server.ts | 20 +- .../settings/server-access-keys/+page.svelte | 72 ++--- .../projects/[slug]/state-iac/+page.server.ts | 37 +++ .../projects/[slug]/vault/+page.server.ts | 32 +++ .../org/[org]/settings/+layout.server.ts | 15 ++ .../org/[org]/settings/global/+page.server.ts | 17 ++ .../server-access-keys/+page.server.ts | 17 ++ .../settings/system-backup/+page.server.ts | 17 ++ 19 files changed, 472 insertions(+), 175 deletions(-) create mode 100644 src/routes/org/[org]/projects/[slug]/state-iac/+page.server.ts create mode 100644 src/routes/org/[org]/projects/[slug]/vault/+page.server.ts create mode 100644 src/routes/org/[org]/settings/+layout.server.ts create mode 100644 src/routes/org/[org]/settings/global/+page.server.ts create mode 100644 src/routes/org/[org]/settings/server-access-keys/+page.server.ts create mode 100644 src/routes/org/[org]/settings/system-backup/+page.server.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c61a558..f1815a4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -53,17 +53,19 @@ Los permisos usan `section:action` con scope global, de organizacion o de proyec **Gating de permisos en UI:** En loaders de rutas, usar `cancanService.canSessionUser()` para verificar permisos específicos y pasarlos a componentes como props (`canCreate`, `canUpdate`, `canDelete`) para ocultar acciones que el usuario no puede realizar. El layout raíz (`+layout.server.ts`) calcula permisos -de lectura granulares para cada sección de settings (proyectos, usuarios, roles, backups, servidor-keys, -audit) y los filtra en AppSidebar según si el usuario tiene acceso a esa sección específica. +granulares de lectura para settings de org (proyectos, usuarios, roles, configuración global, backups, servidor-keys, audit) +y módulos de proyecto (vault, codereport, stateiac) combinándolos con lógica OR (`canReadProjectVault = canReadProjectVaultSecrets || canReadProjectVaultEnvironments`) +para obtener flags de lectura de alto nivel que se filtran en AppSidebar según acceso específico. Roles por defecto en `src/modules/auth/domain/role-permissions.data.ts`. Los permisos incluyen scope como prefijo (ej: `organization:projects:read`, `project:vault:secrets:all`) y se almacenan verbatim sin -transformaciones: +transformaciones. Recursos de módulos tienen sub-permisos granulares (ej: `project:vault:secrets:read`, `project:vault:environments:read`): - **Cluster Admin**: vault, openreport, stateiac (todos) - **Cluster User**: sin permisos propios; rol base para acceso a nivel cluster -- **Organization Admin**: todos los permisos de org (proyectos, usuarios, roles, backups, audit) +- **Organization Admin**: todos los permisos de org (proyectos, usuarios, roles, configuración global, backups, server-access-keys, audit) - **Organization Developer**: solo read/create/update de proyectos -- **Project Admin/Developer/Viewer**: permisos granulares por modulo (vault, codereport, stateiac) +- **Project Admin**: metadata/usuarios/roles/server-keys/audit plus todos los permisos de módulos (vault, codereport, stateiac) +- **Project Developer/Viewer**: acceso granular a módulos con permisos diferenciados (solo lectura en Viewer) Los permisos de organización se propagan a sus proyectos solo cuando no hay un assignment explícito a nivel de proyecto. Un usuario con `organization:projects:read` puede satisfacer `project:project:read` en cualquier diff --git a/CLAUDE.md b/CLAUDE.md index 155f847..52ac90f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,8 +97,8 @@ return { roles, canCreate }; The root layout (`+layout.server.ts`) calculates granular read permissions for each settings section and passes them to the AppSidebar component, which filters sidebar items based on specific resource permissions: -- **Project level:** `project:project:read`, `project:users:read`, `project:roles:read`, `project:server-keys:read`, `project:audit:read` -- **Organization level:** `organization:projects:read`, `organization:users:read`, `organization:roles:read`, `organization:backups:read`, `organization:server-keys:read`, `organization:audit:read` +- **Project level:** `project:project:read`, `project:users:read`, `project:roles:read`, `project:server-keys:read`, `project:audit:read`, plus module-specific permissions: `project:vault:secrets:read`, `project:vault:environments:read`, `project:codereport:reports:read`, `project:codereport:dependencies:read`, `project:codereport:vulnerabilities:read`, `project:stateiac:stacks:read`, `project:stateiac:states:read`, `project:stateiac:history:read`. High-level read flags combine granular permissions with OR logic (e.g., `canReadProjectVault = canReadProjectVaultSecrets || canReadProjectVaultEnvironments`) +- **Organization level:** `organization:projects:read`, `organization:users:read`, `organization:roles:read`, `organization:settings:read`, `organization:backups:read`, `organization:server-keys:read`, `organization:audit:read` This ensures the UI only displays navigation items for sections the user has permission to view. @@ -107,11 +107,13 @@ Permissions always include their scope as a prefix (e.g., `organization:projects `project:vault:secrets:all`) and are stored verbatim—there is no scope-stripping transformation: - **Cluster Admin** (`vault:all`, `openreport:all`, `stateiac:all`) - **Cluster User** (no inherent permissions; used as base role for cluster-level access) -- **Organization Admin** (all org-level permissions: projects, users, roles, backups, audit) +- **Organization Admin** (all org-level permissions: projects, users, roles, settings, backups, server-keys, audit) - **Organization Developer** (read/create/update projects only) -- **Project Admin** (all project-level permissions across vault, codereport, stateiac) -- **Project Developer** (read/create/update resources; no deletion/admin) -- **Project Viewer** (read-only across all project modules) +- **Project Admin** (all project-level permissions: project metadata, users, roles, server-keys, audit; plus all module permissions: vault secrets/environments, codereport reports/dependencies/vulnerabilities, stateiac stacks/states/history) +- **Project Developer** (read/create/update project resources; no deletion or admin; granular module access) +- **Project Viewer** (read-only: project metadata, all modules, vault secrets/environments, codereport reports/dependencies/vulnerabilities, stateiac stacks/states/history) + +Module resources have granular sub-permissions: `project:vault:secrets:read`, `project:vault:environments:read`, `project:codereport:reports:read`, `project:codereport:dependencies:read`, `project:codereport:vulnerabilities:read`, `project:stateiac:stacks:read`, `project:stateiac:states:read`, `project:stateiac:history:read`. These allow fine-grained access control within each module. Organization-level permissions cascade into their projects only when no explicit project-level assignment exists for that user. A user with `organization:projects:read` can satisfy a `project:project:read` check on any project diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte index 81b30ca..d7226c4 100644 --- a/src/lib/components/AppSidebar.svelte +++ b/src/lib/components/AppSidebar.svelte @@ -41,6 +41,7 @@ export let canReadOrgProjects = false; export let canReadOrgUsers = false; export let canReadOrgRoles = false; + export let canReadOrgGlobal = false; export let canReadOrgBackups = false; export let canReadOrgServerKeys = false; export let canReadOrgAudit = false; @@ -49,6 +50,9 @@ export let canReadProjectRoles = false; export let canReadProjectServerKeys = false; export let canReadProjectAudit = false; + export let canReadProjectVault = false; + export let canReadProjectCodeReport = false; + export let canReadProjectStateIac = false; export let currentProjectSlug: string | null = null; export let projects: { slug: string; @@ -98,7 +102,7 @@ }, ], }, - ...(currentProject?.modules?.vault + ...(currentProject?.modules?.vault && canReadProjectVault ? [ { name: $_('sidebar.categories.seguridad'), @@ -118,7 +122,7 @@ }, ] : []), - ...(currentProject?.modules?.codereport + ...(currentProject?.modules?.codereport && canReadProjectCodeReport ? [ { name: $_('sidebar.categories.analisis'), @@ -164,7 +168,7 @@ }, ] : []), - ...(currentProject?.modules?.stateiac + ...(currentProject?.modules?.stateiac && canReadProjectStateIac ? [ { name: $_('sidebar.categories.gitops'), @@ -254,7 +258,7 @@ label: $_('sidebar.items.global'), href: `/org/${organizationSlug}/settings/global`, icon: Shield, - visible: true, + visible: canReadOrgGlobal, }, { label: $_('sidebar.items.accessControl'), diff --git a/src/lib/config/permissions.ts b/src/lib/config/permissions.ts index 4501de2..9df2ab5 100644 --- a/src/lib/config/permissions.ts +++ b/src/lib/config/permissions.ts @@ -43,6 +43,10 @@ const permissions = { resource: 'roles', permissions: grantsFor('organization', 'roles'), }, + settings: { + resource: 'settings', + permissions: grantsFor('organization', 'settings'), + }, backups: { resource: 'backups', permissions: grantsFor('organization', 'backups'), diff --git a/src/modules/auth/domain/role-permissions.data.ts b/src/modules/auth/domain/role-permissions.data.ts index 74fbc6b..f897ff2 100644 --- a/src/modules/auth/domain/role-permissions.data.ts +++ b/src/modules/auth/domain/role-permissions.data.ts @@ -11,6 +11,7 @@ export const ORGANIZATION_ADMIN_PERMISSIONS = [ 'organization:projects:all', 'organization:users:all', 'organization:roles:all', + 'organization:settings:all', 'organization:backups:all', 'organization:server-keys:all', 'organization:audit:all', diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 51664ea..2a264eb 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -48,6 +48,7 @@ export async function load({ locals, url }) { canReadOrgProjects, canReadOrgUsers, canReadOrgRoles, + canReadOrgGlobal, canReadOrgBackups, canReadOrgServerKeys, canReadOrgAudit, @@ -55,6 +56,7 @@ export async function load({ locals, url }) { orgSectionPermission('organization:projects:read'), orgSectionPermission('organization:users:read'), orgSectionPermission('organization:roles:read'), + orgSectionPermission('organization:settings:read'), orgSectionPermission('organization:backups:read'), orgSectionPermission('organization:server-keys:read'), orgSectionPermission('organization:audit:read'), @@ -108,14 +110,38 @@ export async function load({ locals, url }) { canReadProjectRoles, canReadProjectServerKeys, canReadProjectAudit, + canReadProjectVaultSecrets, + canReadProjectVaultEnvironments, + canReadProjectCodeReportReports, + canReadProjectCodeReportDependencies, + canReadProjectCodeReportVulnerabilities, + canReadProjectStateIacStacks, + canReadProjectStateIacStates, + canReadProjectStateIacHistory, ] = await Promise.all([ projectSectionPermission('project:project:read'), projectSectionPermission('project:users:read'), projectSectionPermission('project:roles:read'), projectSectionPermission('project:server-keys:read'), projectSectionPermission('project:audit:read'), + projectSectionPermission('project:vault:secrets:read'), + projectSectionPermission('project:vault:environments:read'), + projectSectionPermission('project:codereport:reports:read'), + projectSectionPermission('project:codereport:dependencies:read'), + projectSectionPermission('project:codereport:vulnerabilities:read'), + projectSectionPermission('project:stateiac:stacks:read'), + projectSectionPermission('project:stateiac:states:read'), + projectSectionPermission('project:stateiac:history:read'), ]); + const canReadProjectVault = canReadProjectVaultSecrets || canReadProjectVaultEnvironments; + const canReadProjectCodeReport = + canReadProjectCodeReportReports || + canReadProjectCodeReportDependencies || + canReadProjectCodeReportVulnerabilities; + const canReadProjectStateIac = + canReadProjectStateIacStacks || canReadProjectStateIacStates || canReadProjectStateIacHistory; + return { // isConfigured: !!config && backends.length > 0, isConfigured: true, @@ -130,6 +156,7 @@ export async function load({ locals, url }) { canReadOrgProjects, canReadOrgUsers, canReadOrgRoles, + canReadOrgGlobal, canReadOrgBackups, canReadOrgServerKeys, canReadOrgAudit, @@ -138,6 +165,9 @@ export async function load({ locals, url }) { canReadProjectRoles, canReadProjectServerKeys, canReadProjectAudit, + canReadProjectVault, + canReadProjectCodeReport, + canReadProjectStateIac, currentProjectSlug, }; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5a84035..c886724 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -57,6 +57,7 @@ canReadOrgProjects={data.canReadOrgProjects} canReadOrgUsers={data.canReadOrgUsers} canReadOrgRoles={data.canReadOrgRoles} + canReadOrgGlobal={data.canReadOrgGlobal} canReadOrgBackups={data.canReadOrgBackups} canReadOrgServerKeys={data.canReadOrgServerKeys} canReadOrgAudit={data.canReadOrgAudit} @@ -65,6 +66,9 @@ canReadProjectRoles={data.canReadProjectRoles} canReadProjectServerKeys={data.canReadProjectServerKeys} canReadProjectAudit={data.canReadProjectAudit} + canReadProjectVault={data.canReadProjectVault} + canReadProjectCodeReport={data.canReadProjectCodeReport} + canReadProjectStateIac={data.canReadProjectStateIac} currentProjectSlug={data.currentProjectSlug ?? null} bind:collapsed={sidebarCollapsed} /> diff --git a/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.server.ts index c6c6c8b..644103f 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.server.ts @@ -1,12 +1,37 @@ -import { error } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; import { projectService } from '$modules/projects'; -export async function load({ params }) { +export async function load({ params, locals }) { const project = await projectService.getProjectBySlug(params.slug); if (!project) { throw error(404, 'Proyecto no encontrado'); } + const canRead = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:read', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + const canUpdate = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:update', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + const codeReportSettings = project.settings?.['code-report'] || { securityRiskMultipliers: { critical: 10, @@ -18,19 +43,34 @@ export async function load({ params }) { return { settings: codeReportSettings, + canUpdate, }; } export const actions = { - updateRiskMultipliers: async ({ request, params }) => { + updateRiskMultipliers: async ({ request, params, locals }) => { + const project = await projectService.getProjectBySlug(params.slug); + + const canUpdate = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:update', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + + if (!canUpdate) { + return fail(403, { error: 'Forbidden' }); + } + const data = await request.formData(); const critical = Number(data.get('critical')); const high = Number(data.get('high')); const medium = Number(data.get('medium')); const low = Number(data.get('low')); - const project = await projectService.getProjectBySlug(params.slug); - const currentSettings = project.settings || {}; const codeReportSettings = currentSettings['code-report'] || {}; diff --git a/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.svelte index eedc7c1..5bb68f0 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/code-report/settings/+page.svelte @@ -7,6 +7,7 @@ $: settings = data.settings; $: multipliers = settings.securityRiskMultipliers; + $: canUpdate = data.canUpdate; let loading = false; @@ -47,22 +48,22 @@
@@ -82,11 +83,13 @@

{$_('codeReport.settings.moreSettingsDescription')}

-
- -
+ {#if canUpdate} +
+ +
+ {/if}
diff --git a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts index 85a1afe..05dcb3a 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.server.ts @@ -22,13 +22,20 @@ export async function load({ params, locals }) { if (!canRead) throw error(403, 'Forbidden'); - const canDelete = await cancanService.canSessionUser(locals.user, 'project:project:delete', { - scope: 'project', - projectId: project.id, - organizationId: project.organization?.id, - }); - - return { project, canDelete }; + const [canUpdate, canDelete] = await Promise.all([ + cancanService.canSessionUser(locals.user, 'project:project:update', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:project:delete', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + ]); + + return { project, canUpdate, canDelete }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte index 69f4fae..ac43fa6 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/overview/+page.svelte @@ -36,9 +36,10 @@ updatedAt: string; }; - export let data: { project: ProjectRow; canDelete: boolean }; + export let data: { project: ProjectRow; canUpdate: boolean; canDelete: boolean }; $: project = data.project; + $: canUpdate = data.canUpdate; $: orgSlug = $page?.params?.org ?? ''; $: isArchived = project.status === 'inactive'; @@ -262,26 +263,44 @@ - + {#if canUpdate} + + {:else} +

+ {project.name} +

+ {/if}
- + {#if canUpdate} + + {:else} +

+ {project.slug} +

+ {/if}
@@ -289,13 +308,22 @@ - + {#if canUpdate} + + {:else} +

+ {project.description || $_('projectSettings.overview.optionalDescription')} +

+ {/if} @@ -317,7 +345,8 @@ - + {#if canUpdate} +
+ +
+ {/if}
@@ -408,84 +439,88 @@
-
-
-

- {$_('projectSettings.overview.dangerZone')} -

-
- -
-
-
-

- {$_('projectSettings.overview.transferToOrganization')} -

-

- {$_('projectSettings.overview.transferDescription')} -

-
- + {#if canUpdate || data.canDelete} +
+
+

+ {$_('projectSettings.overview.dangerZone')} +

-
-
-

- {isArchived - ? $_('projectSettings.overview.activateProject') - : $_('projectSettings.overview.archiveProject')} -

-

- {isArchived - ? $_('projectSettings.overview.activateDescription') - : $_('projectSettings.overview.archiveDescription')} -

-
- -
+
+ {#if canUpdate} +
+
+

+ {$_('projectSettings.overview.transferToOrganization')} +

+

+ {$_('projectSettings.overview.transferDescription')} +

+
+ +
- {#if data.canDelete} -
-
-

- {$_('projectSettings.overview.deleteProject')} -

-

- {$_('projectSettings.overview.deleteDescription')} -

+
+
+

+ {isArchived + ? $_('projectSettings.overview.activateProject') + : $_('projectSettings.overview.archiveProject')} +

+

+ {isArchived + ? $_('projectSettings.overview.activateDescription') + : $_('projectSettings.overview.archiveDescription')} +

+
+
- -
- {/if} -
-
+ {/if} + + {#if data.canDelete} +
+
+

+ {$_('projectSettings.overview.deleteProject')} +

+

+ {$_('projectSettings.overview.deleteDescription')} +

+
+ +
+ {/if} +
+
+ {/if} {#if archiveModalOpen} diff --git a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts index bbe4b0c..2eed6ca 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts @@ -42,14 +42,32 @@ export async function load({ parent, locals }) { throw error(403, 'Forbidden'); } - const [apiKeys, roles] = await Promise.all([ + const [apiKeys, roles, canCreate, canUpdate, canDelete] = await Promise.all([ apiKeysService.listActiveApiKeysByProject(project.id), roleService.listRoles('project', project.id), + cancanService.canSessionUser(locals.user, 'project:server-keys:create', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:server-keys:update', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:server-keys:delete', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), ]); return { apiKeys, roles: roles.map((role) => ({ id: role.id, name: role.name, slug: role.slug })), + canCreate, + canUpdate, + canDelete, }; } diff --git a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte index 85421b0..7ecd823 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte @@ -15,7 +15,13 @@ type RoleRow = { id: string; name: string; slug: string }; - export let data: { apiKeys: ApiKeyRow[]; roles: RoleRow[] }; + export let data: { + apiKeys: ApiKeyRow[]; + roles: RoleRow[]; + canCreate: boolean; + canUpdate: boolean; + canDelete: boolean; + }; export let form: { success?: boolean; error?: string; createdKey?: string } | null; let createModalOpen = false; @@ -82,14 +88,16 @@ >.

- + {#if data.canCreate} + + {/if} @@ -163,28 +171,32 @@

- {#if !key.revokedAt} + {#if !key.revokedAt && (data.canUpdate || data.canDelete)}
-
- - - -
- - - + {#if data.canUpdate} +
+ + + + {/if} + {#if data.canDelete} +
+ + + + {/if}
{/if} diff --git a/src/routes/org/[org]/projects/[slug]/state-iac/+page.server.ts b/src/routes/org/[org]/projects/[slug]/state-iac/+page.server.ts new file mode 100644 index 0000000..78a2855 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/state-iac/+page.server.ts @@ -0,0 +1,37 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; +import { projectService } from '$modules/projects'; + +export async function load({ params, locals }) { + let project; + try { + project = await projectService.getProjectBySlug(params.slug); + } catch { + throw error(404, 'Project not found'); + } + + const organizationId = project.organization?.id; + const [canReadStacks, canReadStates, canReadHistory] = await Promise.all([ + cancanService.canSessionUser(locals.user, 'project:stateiac:stacks:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + cancanService.canSessionUser(locals.user, 'project:stateiac:states:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + cancanService.canSessionUser(locals.user, 'project:stateiac:history:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + ]); + + if (!canReadStacks && !canReadStates && !canReadHistory) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/projects/[slug]/vault/+page.server.ts b/src/routes/org/[org]/projects/[slug]/vault/+page.server.ts new file mode 100644 index 0000000..22b4373 --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/vault/+page.server.ts @@ -0,0 +1,32 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; +import { projectService } from '$modules/projects'; + +export async function load({ params, locals }) { + let project; + try { + project = await projectService.getProjectBySlug(params.slug); + } catch { + throw error(404, 'Project not found'); + } + + const organizationId = project.organization?.id; + const [canReadSecrets, canReadEnvironments] = await Promise.all([ + cancanService.canSessionUser(locals.user, 'project:vault:secrets:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + cancanService.canSessionUser(locals.user, 'project:vault:environments:read', { + scope: 'project', + projectId: project.id, + organizationId, + }), + ]); + + if (!canReadSecrets && !canReadEnvironments) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/settings/+layout.server.ts b/src/routes/org/[org]/settings/+layout.server.ts new file mode 100644 index 0000000..0c2bbec --- /dev/null +++ b/src/routes/org/[org]/settings/+layout.server.ts @@ -0,0 +1,15 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; + +// shell guard for the whole /settings subtree: CLAUDE.md's canManageOrganization gates this area. +// individual pages still re-check their own finer-grained organization:*:read permission. +export async function load({ parent, locals }) { + const { organization } = await parent(); + + const canManage = await cancanService.canManageOrganization(locals.user, organization.id); + if (!canManage) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/settings/global/+page.server.ts b/src/routes/org/[org]/settings/global/+page.server.ts new file mode 100644 index 0000000..393cccb --- /dev/null +++ b/src/routes/org/[org]/settings/global/+page.server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; + +export async function load({ parent, locals }) { + const { organization } = await parent(); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:settings:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/settings/server-access-keys/+page.server.ts b/src/routes/org/[org]/settings/server-access-keys/+page.server.ts new file mode 100644 index 0000000..95f1dc5 --- /dev/null +++ b/src/routes/org/[org]/settings/server-access-keys/+page.server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; + +export async function load({ parent, locals }) { + const { organization } = await parent(); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:server-keys:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/settings/system-backup/+page.server.ts b/src/routes/org/[org]/settings/system-backup/+page.server.ts new file mode 100644 index 0000000..13123cd --- /dev/null +++ b/src/routes/org/[org]/settings/system-backup/+page.server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; + +export async function load({ parent, locals }) { + const { organization } = await parent(); + + if ( + !(await cancanService.canSessionUser(locals.user, 'organization:backups:read', { + scope: 'organization', + organizationId: organization.id, + })) + ) { + throw error(403, 'Forbidden'); + } + + return {}; +} From 9eab4cf6affb615ad32d339f82213587eacf4136 Mon Sep 17 00:00:00 2001 From: "Liam Verdejo (ES)" Date: Tue, 1 Sep 2026 11:48:07 +0200 Subject: [PATCH 5/5] Feat: Add testing e2e --- .github/copilot-instructions.md | 14 + .github/workflows/ci.yml | 21 + .gitignore | 6 +- CLAUDE.md | 15 + CONTRIBUTING.md | 4 + README.md | 13 + bun.lock | 9 + e2e/fixtures/auth.ts | 42 ++ e2e/fixtures/expect-access.ts | 26 ++ e2e/fixtures/seed.ts | 392 ++++++++++++++++++ e2e/global-setup.ts | 123 ++++++ e2e/specs/api-key-auth.spec.ts | 65 +++ e2e/specs/cluster-settings.spec.ts | 46 ++ e2e/specs/code-report.spec.ts | 100 +++++ e2e/specs/login.spec.ts | 26 ++ e2e/specs/organization-settings.spec.ts | 110 +++++ e2e/specs/project-settings.spec.ts | 184 ++++++++ e2e/specs/sidebar-visibility.spec.ts | 67 +++ e2e/specs/vault-stateiac.spec.ts | 50 +++ package.json | 3 + playwright.config.ts | 21 + src/lib/components/Users.svelte | 73 ++-- .../repositories/user-access.repository.ts | 6 +- .../access-control/+page.svelte | 3 + .../code-report/services/+page.server.ts | 14 +- .../[slug]/code-report/services/+page.svelte | 19 +- .../services/[serviceSlug]/+page.server.ts | 23 +- .../services/[serviceSlug]/+page.svelte | 24 +- .../settings/tools/+page.server.ts | 49 ++- .../code-report/settings/tools/+page.svelte | 15 +- .../settings/access-control/+page.server.ts | 22 +- .../settings/access-control/+page.svelte | 2 + .../[slug]/settings/audit/+page.server.ts | 18 + .../roles-permissions/+page.server.ts | 13 +- .../roles-permissions/[id]/+page.server.ts | 11 + .../roles-permissions/new/+page.server.ts | 7 +- .../settings/access-control/+page.server.ts | 8 +- .../settings/access-control/+page.svelte | 9 +- .../roles-permissions/new/+page.server.ts | 7 +- tsconfig.json | 9 +- 40 files changed, 1590 insertions(+), 79 deletions(-) create mode 100644 e2e/fixtures/auth.ts create mode 100644 e2e/fixtures/expect-access.ts create mode 100644 e2e/fixtures/seed.ts create mode 100644 e2e/global-setup.ts create mode 100644 e2e/specs/api-key-auth.spec.ts create mode 100644 e2e/specs/cluster-settings.spec.ts create mode 100644 e2e/specs/code-report.spec.ts create mode 100644 e2e/specs/login.spec.ts create mode 100644 e2e/specs/organization-settings.spec.ts create mode 100644 e2e/specs/project-settings.spec.ts create mode 100644 e2e/specs/sidebar-visibility.spec.ts create mode 100644 e2e/specs/vault-stateiac.spec.ts create mode 100644 playwright.config.ts create mode 100644 src/routes/org/[org]/projects/[slug]/settings/audit/+page.server.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f1815a4..a36ddf2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,6 +15,7 @@ Google SSO y SAML son configuracion, no estrategias de autenticacion implementad - Bun como package manager - GitDB como unica capa de persistencia - Vitest, ESLint y Prettier +- Playwright para tests e2e de RBAC (`e2e/`) Comandos: @@ -25,12 +26,25 @@ bun run build bun run check bun run lint bun run test +bun run test:e2e +bun run test:e2e:ui bun run format:check ``` Los tests usan Vitest con `bun run test`. No usar `bun test`: el runner nativo de Bun no carga los aliases ni plugins de Vite/SvelteKit del proyecto. +Aliases de importación (`tsconfig.json`): Usar `$modules` para módulos de lógica de negocio, +`$lib` para componentes y utilidades compartidas, y rutas relativas para imports locales. +Ej: `import { userService } from '$modules/auth'` (no `../../modules/auth`). + +`bun run test:e2e` ejecuta la suite RBAC de Playwright bajo `e2e/` (requiere +`bunx playwright install --with-deps chromium` una sola vez). `e2e/global-setup.ts` crea su +propio repositorio local GitDB descartable, siembra todas las personas de la matriz de permisos +e inicia el servidor dev contra él—nunca toca el repositorio configurado en `.env`. Las cookies +de sesión se emiten directamente (mismo esquema HMAC que `SessionService`), sin necesidad de +automatizar el login excepto en `e2e/specs/login.spec.ts`. + ## Arquitectura La logica vive en `src/modules//` con `domain/`, `application/`, `infrastructure/` e diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44a96d9..f9654e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,3 +41,24 @@ jobs: - name: Run unit tests run: bun run test + + e2e-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + - name: Run RBAC e2e tests + run: bun run test:e2e diff --git a/.gitignore b/.gitignore index bae85c5..01d6ffa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,8 @@ data/ .env .claude/.claude-md-review-state code-report-analysis/ -.gitdb*/ \ No newline at end of file +.gitdb*/ +e2e/.tmp/ +test-results/ +playwright-report/ +blob-report/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 52ac90f..0dbc92f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ The roadmap is in `IDEAS.md`. Do not describe roadmap items as implemented featu - Bun package manager; do not use npm or yarn - GitDB (`@getgitops/gitdb`) as the only persistence layer - Vitest, ESLint, and Prettier +- Playwright for RBAC end-to-end tests (`e2e/`) ```bash bun install @@ -27,12 +28,26 @@ bun run build bun run check bun run lint bun run test +bun run test:e2e +bun run test:e2e:ui bun run format:check ``` Tests use Vitest through `bun run test`. Do not use the native `bun test` runner: it does not load the Vite/SvelteKit aliases and plugins used by this project. +Import aliases (`tsconfig.json`): Use `$modules` for business logic modules, `$lib` for shared +components and utilities, and relative paths for route-local imports. Examples: +`import { userService } from '$modules/auth'` (not `../../modules/auth`), +`import Button from '$lib/components/Button.svelte'`. + +`bun run test:e2e` runs the Playwright RBAC suite under `e2e/` (requires +`bunx playwright install --with-deps chromium` once). `e2e/global-setup.ts` creates its own +throwaway local GitDB repository, seeds every persona the permission matrix needs, and starts the +dev server against it — it never touches the repository configured in `.env`. Session cookies are +minted directly (same HMAC scheme as `SessionService`), so specs don't need to drive the login +form except in `e2e/specs/login.spec.ts`, which covers that mechanism itself. + ## Architecture Business logic lives in `src/modules//`: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 353034f..3605ea7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,6 +47,10 @@ Tests use Vitest through `bun run test`. The native `bun test` runner does not l Add or update focused tests for changed behavior. Authorization, persistence, API handlers, and security-sensitive changes should always include tests where practical. +RBAC changes should also be covered in the Playwright suite under `e2e/` (`bun run test:e2e`, +`bunx playwright install --with-deps chromium` once beforehand). It runs against a throwaway, +self-seeded GitDB instance — see the "End-to-end tests" section in README.md. + ## Pull requests - Explain the problem and the approach. diff --git a/README.md b/README.md index e7306e7..ee7c908 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,19 @@ bun run test bun run format:check ``` +### End-to-end tests + +RBAC (roles/permissions across every catalog resource) is covered by a Playwright suite under +`e2e/`. It's fully self-contained: `e2e/global-setup.ts` creates a throwaway local GitDB +repository, seeds it directly with every persona the matrix needs, and starts the dev server +against it — it never touches the repository configured in your own `.env`. + +```bash +bunx playwright install --with-deps chromium # once +bun run test:e2e +bun run test:e2e:ui # interactive UI mode +``` + ## Contributing Forks and first-time contributors are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) for the diff --git a/bun.lock b/bun.lock index 07faa29..b86069a 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.62.1", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-node": "^5.2.11", "@sveltejs/kit": "^2.70.3", @@ -205,6 +206,8 @@ "@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], @@ -763,6 +766,10 @@ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="], @@ -939,6 +946,8 @@ "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], "svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts new file mode 100644 index 0000000..a902add --- /dev/null +++ b/e2e/fixtures/auth.ts @@ -0,0 +1,42 @@ +import { test as base } from '@playwright/test'; +import crypto from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { E2E_BASE_URL, E2E_ENCRYPTION_KEY, SEED_OUTPUT_PATH } from '../global-setup'; +import type { PersonaKey, SeedOutput } from './seed'; + +let cachedSeed: SeedOutput | null = null; + +export function getSeed(): SeedOutput { + if (!cachedSeed) { + cachedSeed = JSON.parse(readFileSync(SEED_OUTPUT_PATH, 'utf-8')) as SeedOutput; + } + return cachedSeed; +} + +export function sessionTokenFor(userId: string): string { + const signature = crypto.createHmac('sha256', E2E_ENCRYPTION_KEY).update(userId).digest('hex'); + return `${userId}.${signature}`; +} + +type Fixtures = { + loginAs: (personaKey: PersonaKey) => Promise; +}; + +export const test = base.extend({ + loginAs: async ({ context }, use) => { + await use(async (personaKey: PersonaKey) => { + const seed = getSeed(); + const persona = seed.personas[personaKey]; + if (!persona) throw new Error(`Unknown persona: ${personaKey}`); + await context.addCookies([ + { + name: 'pos_session', + value: sessionTokenFor(persona.userId), + url: E2E_BASE_URL, + }, + ]); + }); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/fixtures/expect-access.ts b/e2e/fixtures/expect-access.ts new file mode 100644 index 0000000..2587692 --- /dev/null +++ b/e2e/fixtures/expect-access.ts @@ -0,0 +1,26 @@ +import type { Page, Response } from '@playwright/test'; +import { expect } from '@playwright/test'; + +async function gotoWithResponse(page: Page, path: string): Promise { + const response = await page.goto(path); + if (response) return response; + return page.goto(path); +} + +export async function expectDenied(page: Page, path: string) { + const response = await gotoWithResponse(page, path); + const requestedPath = new URL(path, page.url()).pathname; + const finalPath = new URL(page.url()).pathname; + const status = response?.status() ?? 0; + expect(finalPath !== requestedPath || status === 403, `expected ${path} to be denied`).toBe( + true, + ); +} + +export async function expectAllowed(page: Page, path: string) { + const response = await gotoWithResponse(page, path); + const requestedPath = new URL(path, page.url()).pathname; + const finalPath = new URL(page.url()).pathname; + expect(finalPath, `expected ${path} not to redirect away`).toBe(requestedPath); + expect(response?.status(), `expected ${path} to load successfully`).toBeLessThan(400); +} diff --git a/e2e/fixtures/seed.ts b/e2e/fixtures/seed.ts new file mode 100644 index 0000000..2593223 --- /dev/null +++ b/e2e/fixtures/seed.ts @@ -0,0 +1,392 @@ +import { apiKeysService, roleService, userAccessService } from '$modules/auth'; +import { organizationService } from '$modules/organization'; +import { projectService } from '$modules/projects'; +import { codeReportService } from '$modules/code-report'; + +export const E2E_PASSWORD = 'E2ePassword!123'; + +export type PersonaKey = + | 'clusterAdmin' + | 'clusterUserNoAccess' + | 'orgAdmin' + | 'orgDeveloper' + | 'orgOutsider' + | 'orgNone' + | 'orgProjectsReadOnly' + | 'projectAdmin' + | 'projectDeveloper' + | 'projectViewer' + | 'projectReadOnly' + | 'projectServerKeysNoDelete' + | 'projectServerKeysNoUpdate' + | 'projectUsersNoUpdate' + | 'vaultSecretsOnly' + | 'vaultEnvironmentsOnly' + | 'stateiacStacksOnly' + | 'projectOutsider'; + +export type PersonaSeed = { + userId: string; + username: string; +}; + +export type SeedOutput = { + personas: Record; + primaryOrgSlug: string; + primaryOrgId: string; + primaryProjectSlug: string; + outsiderOrgSlug: string; + outsiderProjectSlug: string; + seededServiceSlug: string; + apiKeys: { + readOnly: string; + write: string; + outsiderProject: string; + }; +}; + +const CLUSTER_ADMIN_PERMISSIONS = [ + 'cluster:organization:all', + 'cluster:projects:all', + 'cluster:users:all', + 'cluster:settings:all', +]; + +function roleIdBySlug(roles: { id: string; slug: string }[], slug: string): string { + const role = roles.find((r) => r.slug === slug); + if (!role) throw new Error(`Seed role not found: ${slug}`); + return role.id; +} + +export async function seedAll(): Promise { + const clusterAdminRole = await roleService.createRole({ + name: 'Cluster Admin', + slug: 'cluster-admin', + scope: 'cluster', + permissions: CLUSTER_ADMIN_PERMISSIONS, + }); + const clusterUserRole = await roleService.createRole({ + name: 'Cluster User', + slug: 'cluster-user', + scope: 'cluster', + permissions: [], + }); + + const clusterAdmin = await userAccessService.createClusterUser({ + username: 'e2e-cluster-admin', + password: E2E_PASSWORD, + roleId: clusterAdminRole.id, + email: 'cluster-admin@e2e.test', + }); + + const clusterUserNoAccess = await userAccessService.createClusterUser({ + username: 'e2e-cluster-user-none', + password: E2E_PASSWORD, + roleId: clusterUserRole.id, + email: 'cluster-user-none@e2e.test', + }); + + async function bareUser(username: string) { + return userAccessService.createClusterUser({ + username, + password: E2E_PASSWORD, + roleId: clusterUserRole.id, + email: `${username}@e2e.test`, + }); + } + + async function assignProjectRole(projectId: string, userId: string, roleId: string) { + await userAccessService.assignProjectUser({ projectId, userId, roleId }); + } + + // --- primary organization ------------------------------------------------------------- + const primaryOrg = await organizationService.createOrganization({ + name: 'E2E Primary Org', + slug: 'e2e-primary', + }); + await roleService.createDefaultOrganizationRoles(primaryOrg.id); + const primaryOrgRoles = await roleService.listRoles('organization', primaryOrg.id); + const orgAdminRoleId = roleIdBySlug(primaryOrgRoles, 'org-admin'); + const orgDeveloperRoleId = roleIdBySlug(primaryOrgRoles, 'org-developer'); + + const orgAdmin = await userAccessService.createOrganizationUser({ + organizationId: primaryOrg.id, + username: 'e2e-org-admin', + password: E2E_PASSWORD, + roleId: orgAdminRoleId, + email: 'org-admin@e2e.test', + }); + const orgDeveloper = await userAccessService.createOrganizationUser({ + organizationId: primaryOrg.id, + username: 'e2e-org-developer', + password: E2E_PASSWORD, + roleId: orgDeveloperRoleId, + email: 'org-developer@e2e.test', + }); + + const orgProjectsReadOnlyRole = await roleService.createRole({ + name: 'E2E Org Projects Read Only', + slug: 'e2e-org-projects-read-only', + scope: 'organization', + organizationId: primaryOrg.id, + permissions: ['organization:projects:read'], + }); + const orgProjectsReadOnly = await userAccessService.createOrganizationUser({ + organizationId: primaryOrg.id, + username: 'e2e-org-projects-read-only', + password: E2E_PASSWORD, + roleId: orgProjectsReadOnlyRole.id, + email: 'org-projects-read-only@e2e.test', + }); + + // --- primary project under the primary org ---------------------------------------------- + const primaryProject = await projectService.createProject({ + organizationId: primaryOrg.id, + name: 'E2E Primary Project', + slug: 'e2e-project', + modules: { vault: true, codereport: true, stateiac: true }, + }); + await roleService.createDefaultProjectRoles(primaryProject.id); + const primaryProjectRoles = await roleService.listRoles('project', primaryProject.id); + const projectAdminRoleId = roleIdBySlug(primaryProjectRoles, 'project-admin'); + const projectDeveloperRoleId = roleIdBySlug(primaryProjectRoles, 'project-developer'); + const projectViewerRoleId = roleIdBySlug(primaryProjectRoles, 'project-viewer'); + + const projectAdminUser = await bareUser('e2e-project-admin'); + await assignProjectRole(primaryProject.id, projectAdminUser.userId, projectAdminRoleId); + + const projectDeveloperUser = await bareUser('e2e-project-developer'); + await assignProjectRole(primaryProject.id, projectDeveloperUser.userId, projectDeveloperRoleId); + + const projectViewerUser = await bareUser('e2e-project-viewer'); + await assignProjectRole(primaryProject.id, projectViewerUser.userId, projectViewerRoleId); + + const orgNoneUser = await bareUser('e2e-org-none'); + await assignProjectRole(primaryProject.id, orgNoneUser.userId, projectViewerRoleId); + + const readOnlyRole = await roleService.createRole({ + name: 'E2E Project Read Only', + slug: 'e2e-project-read-only', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read'], + }); + const projectReadOnlyUser = await bareUser('e2e-project-read-only'); + await assignProjectRole(primaryProject.id, projectReadOnlyUser.userId, readOnlyRole.id); + + const serverKeysNoDeleteRole = await roleService.createRole({ + name: 'E2E Server Keys No Delete', + slug: 'e2e-server-keys-no-delete', + scope: 'project', + projectId: primaryProject.id, + permissions: [ + 'project:project:read', + 'project:server-keys:read', + 'project:server-keys:create', + 'project:server-keys:update', + ], + }); + const serverKeysNoDeleteUser = await bareUser('e2e-server-keys-no-delete'); + await assignProjectRole(primaryProject.id, serverKeysNoDeleteUser.userId, serverKeysNoDeleteRole.id); + + const serverKeysNoUpdateRole = await roleService.createRole({ + name: 'E2E Server Keys No Update', + slug: 'e2e-server-keys-no-update', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read', 'project:server-keys:read', 'project:server-keys:create'], + }); + const serverKeysNoUpdateUser = await bareUser('e2e-server-keys-no-update'); + await assignProjectRole(primaryProject.id, serverKeysNoUpdateUser.userId, serverKeysNoUpdateRole.id); + + await apiKeysService.createProjectApiKey({ + projectId: primaryProject.id, + roleId: projectAdminRoleId, + name: 'e2e-seeded-key-no-delete', + expiresAt: null, + createdByUserId: clusterAdmin.userId, + }); + await apiKeysService.createProjectApiKey({ + projectId: primaryProject.id, + roleId: projectAdminRoleId, + name: 'e2e-seeded-key-no-update', + expiresAt: null, + createdByUserId: clusterAdmin.userId, + }); + + const usersNoUpdateRole = await roleService.createRole({ + name: 'E2E Users No Update', + slug: 'e2e-users-no-update', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read', 'project:users:read'], + }); + const usersNoUpdateUser = await bareUser('e2e-users-no-update'); + await assignProjectRole(primaryProject.id, usersNoUpdateUser.userId, usersNoUpdateRole.id); + + const vaultSecretsOnlyRole = await roleService.createRole({ + name: 'E2E Vault Secrets Only', + slug: 'e2e-vault-secrets-only', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read', 'project:vault:secrets:read'], + }); + const vaultSecretsOnlyUser = await bareUser('e2e-vault-secrets-only'); + await assignProjectRole(primaryProject.id, vaultSecretsOnlyUser.userId, vaultSecretsOnlyRole.id); + + const vaultEnvironmentsOnlyRole = await roleService.createRole({ + name: 'E2E Vault Environments Only', + slug: 'e2e-vault-environments-only', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read', 'project:vault:environments:read'], + }); + const vaultEnvironmentsOnlyUser = await bareUser('e2e-vault-environments-only'); + await assignProjectRole( + primaryProject.id, + vaultEnvironmentsOnlyUser.userId, + vaultEnvironmentsOnlyRole.id, + ); + + const stateiacStacksOnlyRole = await roleService.createRole({ + name: 'E2E StateIac Stacks Only', + slug: 'e2e-stateiac-stacks-only', + scope: 'project', + projectId: primaryProject.id, + permissions: ['project:project:read', 'project:stateiac:stacks:read'], + }); + const stateiacStacksOnlyUser = await bareUser('e2e-stateiac-stacks-only'); + await assignProjectRole( + primaryProject.id, + stateiacStacksOnlyUser.userId, + stateiacStacksOnlyRole.id, + ); + + const seededService = await codeReportService.createService({ + project: primaryProject.slug, + name: 'E2E Seeded Service', + }); + + const { token: readOnlyApiKeyToken } = await apiKeysService.createProjectApiKey({ + projectId: primaryProject.id, + roleId: projectViewerRoleId, + name: 'e2e-api-key-read-only', + expiresAt: null, + createdByUserId: clusterAdmin.userId, + }); + const { token: writeApiKeyToken } = await apiKeysService.createProjectApiKey({ + projectId: primaryProject.id, + roleId: projectDeveloperRoleId, + name: 'e2e-api-key-write', + expiresAt: null, + createdByUserId: clusterAdmin.userId, + }); + + const outsiderOrg = await organizationService.createOrganization({ + name: 'E2E Outsider Org', + slug: 'e2e-outsider', + }); + await roleService.createDefaultOrganizationRoles(outsiderOrg.id); + const outsiderOrgRoles = await roleService.listRoles('organization', outsiderOrg.id); + const outsiderOrgAdminRoleId = roleIdBySlug(outsiderOrgRoles, 'org-admin'); + + const orgOutsider = await userAccessService.createOrganizationUser({ + organizationId: outsiderOrg.id, + username: 'e2e-org-outsider', + password: E2E_PASSWORD, + roleId: outsiderOrgAdminRoleId, + email: 'org-outsider@e2e.test', + }); + + const outsiderProject = await projectService.createProject({ + organizationId: outsiderOrg.id, + name: 'E2E Outsider Project', + slug: 'e2e-outsider-project', + modules: { vault: true, codereport: true, stateiac: true }, + }); + await roleService.createDefaultProjectRoles(outsiderProject.id); + const outsiderProjectRoles = await roleService.listRoles('project', outsiderProject.id); + const outsiderProjectAdminRoleId = roleIdBySlug(outsiderProjectRoles, 'project-admin'); + + const projectOutsiderUser = await bareUser('e2e-project-outsider'); + await assignProjectRole( + outsiderProject.id, + projectOutsiderUser.userId, + outsiderProjectAdminRoleId, + ); + + const { token: outsiderApiKeyToken } = await apiKeysService.createProjectApiKey({ + projectId: outsiderProject.id, + roleId: outsiderProjectAdminRoleId, + name: 'e2e-api-key-outsider', + expiresAt: null, + createdByUserId: clusterAdmin.userId, + }); + + return { + personas: { + clusterAdmin: { userId: clusterAdmin.userId, username: clusterAdmin.username }, + clusterUserNoAccess: { + userId: clusterUserNoAccess.userId, + username: clusterUserNoAccess.username, + }, + orgAdmin: { userId: orgAdmin.userId, username: orgAdmin.username }, + orgDeveloper: { userId: orgDeveloper.userId, username: orgDeveloper.username }, + orgOutsider: { userId: orgOutsider.userId, username: orgOutsider.username }, + orgNone: { userId: orgNoneUser.userId, username: orgNoneUser.username }, + orgProjectsReadOnly: { + userId: orgProjectsReadOnly.userId, + username: orgProjectsReadOnly.username, + }, + projectAdmin: { userId: projectAdminUser.userId, username: projectAdminUser.username }, + projectDeveloper: { + userId: projectDeveloperUser.userId, + username: projectDeveloperUser.username, + }, + projectViewer: { userId: projectViewerUser.userId, username: projectViewerUser.username }, + projectReadOnly: { + userId: projectReadOnlyUser.userId, + username: projectReadOnlyUser.username, + }, + projectServerKeysNoDelete: { + userId: serverKeysNoDeleteUser.userId, + username: serverKeysNoDeleteUser.username, + }, + projectServerKeysNoUpdate: { + userId: serverKeysNoUpdateUser.userId, + username: serverKeysNoUpdateUser.username, + }, + projectUsersNoUpdate: { + userId: usersNoUpdateUser.userId, + username: usersNoUpdateUser.username, + }, + vaultSecretsOnly: { + userId: vaultSecretsOnlyUser.userId, + username: vaultSecretsOnlyUser.username, + }, + vaultEnvironmentsOnly: { + userId: vaultEnvironmentsOnlyUser.userId, + username: vaultEnvironmentsOnlyUser.username, + }, + stateiacStacksOnly: { + userId: stateiacStacksOnlyUser.userId, + username: stateiacStacksOnlyUser.username, + }, + projectOutsider: { + userId: projectOutsiderUser.userId, + username: projectOutsiderUser.username, + }, + }, + primaryOrgSlug: primaryOrg.slug, + primaryOrgId: primaryOrg.id, + primaryProjectSlug: primaryProject.slug, + outsiderOrgSlug: outsiderOrg.slug, + outsiderProjectSlug: outsiderProject.slug, + seededServiceSlug: seededService.slug, + apiKeys: { + readOnly: readOnlyApiKeyToken, + write: writeApiKeyToken, + outsiderProject: outsiderApiKeyToken, + }, + }; +} diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..85fcdb2 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,123 @@ +// Owns the whole E2E lifecycle so there's no ambiguity around Playwright's webServer-vs- +// globalSetup ordering: creates a throwaway local GitDB "remote", seeds it directly (in this +// process) with every persona the RBAC matrix needs, then spawns the dev server pointed at the +// already-cloned data path and waits for it to be ready. Never touches the real dev GitDB +// (gitops-db-local / dev/liam) configured in the project's own .env. +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const E2E_PORT = 5390; +export const E2E_BASE_URL = `http://localhost:${E2E_PORT}`; +export const E2E_ENCRYPTION_KEY = 'e2e-test-encryption-key-do-not-use-in-prod'; + +const ROOT = path.resolve(__dirname, '..'); +// deliberately OUTSIDE the repo (not e2e/.tmp): a throwaway git repo nested inside this repo's +// working tree is one race away from disaster — if GitDB's own git commands ever run before its +// `.git` finishes initializing, git walks up looking for a repo and silently commits into THIS +// project's history instead (which happened once while building this suite — see git log if +// curious, it was cleaned up with `git reset --soft`). Living under the OS temp dir means that +// walk-up has nothing to find. +const TMP_DIR = path.join(os.tmpdir(), 'gitops-platform-e2e'); +const REMOTE_DIR = path.join(TMP_DIR, 'gitdb-remote'); +const DATA_PATH = path.join(TMP_DIR, 'gitdb-data'); +export const SEED_OUTPUT_PATH = path.join(TMP_DIR, 'seed-output.json'); + +function initThrowawayRemote() { + rmSync(TMP_DIR, { recursive: true, force: true }); + mkdirSync(REMOTE_DIR, { recursive: true }); + + const git = (args: string[]) => execFileSync('git', args, { cwd: REMOTE_DIR, stdio: 'ignore' }); + git(['init', '-b', 'main']); + git(['-c', 'user.email=e2e@gitops.local', '-c', 'user.name=e2e', 'commit', '--allow-empty', '-m', 'init']); +} + +function buildEnv(): Record { + return { + ...process.env, + GITDB_REPOSITORY_URL: REMOTE_DIR, + GITDB_BRANCH: 'main', + GITDB_DATA_PATH: DATA_PATH, + GITDB_ENCRYPTION_KEY: E2E_ENCRYPTION_KEY, + GITDB_AUTHOR_NAME: 'e2e', + GITDB_AUTHOR_EMAIL: 'e2e@gitops.local', + GITDB_SYNC_POLL_SECONDS: '86400', + GITDB_USERNAME: '', + GITDB_TOKEN: '', + GITDB_PASSWORD: '', + PORT: String(E2E_PORT), + }; +} + +async function waitForServer(url: string, timeoutMs: number) { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { redirect: 'manual' }); + // any HTTP response (even a redirect to /bootstrap or /maintenance) means the process + // is up; we only need "port is listening and hooks.server.ts responded". + if (response.status > 0) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + throw new Error(`Dev server did not become ready at ${url}: ${String(lastError)}`); +} + +export default async function globalSetup() { + initThrowawayRemote(); + + const env = buildEnv(); + Object.assign(process.env, env); + + const { seedAll } = await import('./fixtures/seed'); + const seedOutput = await seedAll(); + writeFileSync(SEED_OUTPUT_PATH, JSON.stringify(seedOutput, null, 2)); + + // `bun run build` currently fails in this repo independent of e2e/RBAC work (a Tailwind/ + // postcss resolution error under Vite 8 — pre-existing, out of scope here), so `vite preview` + // isn't usable; run the dev server instead. Dev-mode SSR has a separate, pre-existing + // svelte-i18n race on a truly cold server (locale loader hasn't resolved before the first + // synchronous SSR render) — worked around below with a couple of throwaway warm-up requests + // before tests start, rather than touching app code for an unrelated bug. + const server: ChildProcess = spawn( + 'bun', + ['run', 'dev', '--', '--port', String(E2E_PORT), '--strictPort'], + { + cwd: ROOT, + env, + stdio: 'inherit', + }, + ); + + let serverExited = false; + server.on('exit', () => { + serverExited = true; + }); + + await waitForServer(`${E2E_BASE_URL}/auth/login`, 60_000); + if (serverExited) { + throw new Error('Dev server exited before becoming ready'); + } + + // absorb the cold-start svelte-i18n race (see comment above) so real test navigations don't + // hit it: a couple of real page loads, spaced out, until one actually returns 200. + for (let attempt = 0; attempt < 10; attempt += 1) { + const response = await fetch(`${E2E_BASE_URL}/auth/login`); + console.log(`[e2e warmup] attempt ${attempt} -> ${response.status}`); + if (response.status === 200) break; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + return async () => { + if (!server.killed) { + server.kill(); + } + }; +} diff --git a/e2e/specs/api-key-auth.spec.ts b/e2e/specs/api-key-auth.spec.ts new file mode 100644 index 0000000..ce59b1b --- /dev/null +++ b/e2e/specs/api-key-auth.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import { getSeed } from '../fixtures/auth'; + +const seed = getSeed(); +const scanUrl = '/api/code-report/scan'; + +function startBody(overrides: Record = {}) { + return { + status: 'start', + service: seed.seededServiceSlug, + project: seed.primaryProjectSlug, + tool: 'trivy', + ...overrides, + }; +} + +test('no Authorization header is rejected', async ({ request }) => { + const response = await request.post(scanUrl, { data: startBody() }); + expect(response.status()).toBe(401); +}); + +test('an invalid bearer token is rejected', async ({ request }) => { + const response = await request.post(scanUrl, { + headers: { Authorization: 'Bearer gvs_not_a_real_token' }, + data: startBody(), + }); + expect(response.status()).toBe(401); +}); + +test('a read-only key (no codereport:create) is denied', async ({ request }) => { + const response = await request.post(scanUrl, { + headers: { Authorization: `Bearer ${seed.apiKeys.readOnly}` }, + data: startBody(), + }); + expect(response.status()).toBe(403); +}); + +test('a key with codereport:create can start a scan for its own project', async ({ request }) => { + const response = await request.post(scanUrl, { + headers: { Authorization: `Bearer ${seed.apiKeys.write}` }, + data: startBody(), + }); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.success).toBe(true); +}); + +test('a key scoped to a different project is denied (cross-project isolation)', async ({ + request, +}) => { + // valid, working key — just scoped to the outsider project, not the primary one it's targeting + const response = await request.post(scanUrl, { + headers: { Authorization: `Bearer ${seed.apiKeys.outsiderProject}` }, + data: startBody(), + }); + expect(response.status()).toBe(403); +}); + +test('a project-scoped key cannot be used on a non-API browser route', async ({ request }) => { + const response = await request.get( + `/org/${seed.primaryOrgSlug}/projects/${seed.primaryProjectSlug}/settings/overview`, + { headers: { Authorization: `Bearer ${seed.apiKeys.write}` } }, + ); + expect(response.status()).toBe(401); +}); diff --git a/e2e/specs/cluster-settings.spec.ts b/e2e/specs/cluster-settings.spec.ts new file mode 100644 index 0000000..6f730e7 --- /dev/null +++ b/e2e/specs/cluster-settings.spec.ts @@ -0,0 +1,46 @@ +import { test } from '../fixtures/auth'; +import { expectAllowed, expectDenied } from '../fixtures/expect-access'; + +const clusterRoutes = [ + '/cluster-settings/orgs', + '/cluster-settings/roles-permissions', + '/cluster-settings/access-control', + '/cluster-settings/database', + '/cluster-settings/audit', +]; + +test.describe('cluster settings — cluster admin', () => { + test('sees every cluster settings resource', async ({ page, loginAs }) => { + await loginAs('clusterAdmin'); + for (const route of clusterRoutes) { + await expectAllowed(page, route); + } + }); +}); + +test.describe('cluster settings — org admin (not a cluster admin)', () => { + test('is denied every cluster settings resource', async ({ page, loginAs }) => { + await loginAs('orgAdmin'); + for (const route of clusterRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('cluster settings — project admin (not a cluster admin)', () => { + test('is denied every cluster settings resource', async ({ page, loginAs }) => { + await loginAs('projectAdmin'); + for (const route of clusterRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('cluster settings — bare cluster-user role', () => { + test('is denied every cluster settings resource', async ({ page, loginAs }) => { + await loginAs('clusterUserNoAccess'); + for (const route of clusterRoutes) { + await expectDenied(page, route); + } + }); +}); diff --git a/e2e/specs/code-report.spec.ts b/e2e/specs/code-report.spec.ts new file mode 100644 index 0000000..02eb7c9 --- /dev/null +++ b/e2e/specs/code-report.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from '../fixtures/auth'; +import { expectAllowed, expectDenied } from '../fixtures/expect-access'; +import { getSeed } from '../fixtures/auth'; + +const seed = getSeed(); +const base = `/org/${seed.primaryOrgSlug}/projects/${seed.primaryProjectSlug}/code-report`; + +const readOnlyRoutes = [ + `${base}/dashboard`, + `${base}/history`, + `${base}/security-policy`, + `${base}/services`, + `${base}/services/${seed.seededServiceSlug}`, + `${base}/settings`, + `${base}/settings/tools`, +]; + +test.describe('code report — no codereport:*:read grant at all', () => { + test('a project:project:read-only role is denied every sub-page', async ({ + page, + loginAs, + }) => { + await loginAs('projectReadOnly'); + for (const route of readOnlyRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('code report — project viewer (read, no create/update)', () => { + test('sees every sub-page', async ({ page, loginAs }) => { + await loginAs('projectViewer'); + for (const route of readOnlyRoutes) { + await expectAllowed(page, route); + } + }); + + test('the "add service" button is hidden on the services list', async ({ page, loginAs }) => { + await loginAs('projectViewer'); + await page.goto(`${base}/services`); + await expect(page.getByRole('button', { name: 'Añadir servicio' })).toHaveCount(0); + }); + + test('the delete button is hidden on the service detail page', async ({ page, loginAs }) => { + await loginAs('projectViewer'); + await page.goto(`${base}/services/${seed.seededServiceSlug}`); + await expect(page.getByRole('button', { name: 'Borrar servicio' })).toHaveCount(0); + }); +}); + +test.describe('code report — project developer (has create, not delete)', () => { + test('the "add service" button is visible on the services list', async ({ page, loginAs }) => { + await loginAs('projectDeveloper'); + await page.goto(`${base}/services`); + await expect(page.getByRole('button', { name: 'Añadir servicio' })).toBeVisible(); + }); + + // PROJECT_DEVELOPER_PERMISSIONS grants codereport:reports:create/update but not :delete — + // developer stays read+create/update, only admin gets delete. + test('the delete button is still hidden on the service detail page', async ({ + page, + loginAs, + }) => { + await loginAs('projectDeveloper'); + await page.goto(`${base}/services/${seed.seededServiceSlug}`); + await expect(page.getByRole('button', { name: 'Borrar servicio' })).toHaveCount(0); + }); +}); + +test.describe('code report — project admin (has delete)', () => { + test('the delete button is visible on the service detail page', async ({ page, loginAs }) => { + await loginAs('projectAdmin'); + await page.goto(`${base}/services/${seed.seededServiceSlug}`); + await expect(page.getByRole('button', { name: 'Borrar servicio' })).toBeVisible(); + }); +}); + +test.describe('code report — services create, real mutation', () => { + test('project developer can create a service via POST', async ({ page, loginAs }) => { + await loginAs('projectDeveloper'); + const response = await page.request.post(`${base}/services?/create`, { + form: { name: 'E2E Mutation Service', slug: 'e2e-mutation-service', tags: '' }, + }); + const body = await response.json(); + expect(body.type).not.toBe('failure'); + + await page.goto(`${base}/services`); + await expect(page.getByText('E2E Mutation Service')).toBeVisible(); + }); + + test('project viewer gets a 403 on the same POST', async ({ page, loginAs }) => { + await loginAs('projectViewer'); + const response = await page.request.post(`${base}/services?/create`, { + form: { name: 'Should Not Be Created', slug: 'should-not-be-created-service', tags: '' }, + }); + const body = await response.json(); + expect(body.type).toBe('failure'); + expect(body.status).toBe(403); + }); +}); diff --git a/e2e/specs/login.spec.ts b/e2e/specs/login.spec.ts new file mode 100644 index 0000000..fb114b6 --- /dev/null +++ b/e2e/specs/login.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; +import { E2E_PASSWORD } from '../fixtures/seed'; +import { getSeed } from '../fixtures/auth'; + +async function gotoLogin(page: import('@playwright/test').Page) { + await page.goto('/auth/login'); + await page.waitForLoadState('networkidle'); +} + +test('logs in through the real form and reaches the app shell', async ({ page }) => { + const seed = getSeed(); + await gotoLogin(page); + await page.getByLabel('Correo electrónico').fill('cluster-admin@e2e.test'); + await page.getByLabel('Contraseña').fill(E2E_PASSWORD); + await page.getByRole('button', { name: 'Iniciar sesión' }).click(); + await expect(page).not.toHaveURL(/\/auth\/login/); + expect(seed.primaryOrgSlug).toBe('e2e-primary'); +}); + +test('rejects a wrong password', async ({ page }) => { + await gotoLogin(page); + await page.getByLabel('Correo electrónico').fill('cluster-admin@e2e.test'); + await page.getByLabel('Contraseña').fill('wrong-password'); + await page.getByRole('button', { name: 'Iniciar sesión' }).click(); + await expect(page).toHaveURL(/\/auth\/login/); +}); diff --git a/e2e/specs/organization-settings.spec.ts b/e2e/specs/organization-settings.spec.ts new file mode 100644 index 0000000..adfa2dc --- /dev/null +++ b/e2e/specs/organization-settings.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from '../fixtures/auth'; +import { expectAllowed, expectDenied } from '../fixtures/expect-access'; +import { getSeed } from '../fixtures/auth'; + +const seed = getSeed(); +const org = seed.primaryOrgSlug; +const settingsRoutes = [ + `/org/${org}/settings/projects`, + `/org/${org}/settings/access-control`, + `/org/${org}/settings/roles-permissions`, + `/org/${org}/settings/global`, + `/org/${org}/settings/system-backup`, + `/org/${org}/settings/server-access-keys`, + `/org/${org}/settings/audit`, +]; + +test.describe('organization settings — org admin', () => { + test('sees every organization settings resource', async ({ page, loginAs }) => { + await loginAs('orgAdmin'); + for (const route of settingsRoutes) { + await expectAllowed(page, route); + } + }); +}); + +test.describe('organization settings — org developer', () => { + test('sees only projects, denied everywhere else', async ({ page, loginAs }) => { + await loginAs('orgDeveloper'); + await expectAllowed(page, `/org/${org}/settings/projects`); + for (const route of settingsRoutes.filter((r) => !r.endsWith('/projects'))) { + await expectDenied(page, route); + } + }); +}); + +test.describe('organization settings — no org-scope role (project-only access)', () => { + test('sees the org overview but is denied every settings page', async ({ page, loginAs }) => { + await loginAs('orgNone'); + await expectAllowed(page, `/org/${org}/overview`); + for (const route of settingsRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('organization settings — cross-org isolation', () => { + test('a member of a different org is denied every settings page here', async ({ + page, + loginAs, + }) => { + await loginAs('orgOutsider'); + for (const route of settingsRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('organization settings — no access at all', () => { + test('a bare cluster-user is denied everything', async ({ page, loginAs }) => { + await loginAs('clusterUserNoAccess'); + await expectDenied(page, `/org/${org}/overview`); + for (const route of settingsRoutes) { + await expectDenied(page, route); + } + }); +}); + +test.describe('organization roles — "new role" form requires organization:roles:create', () => { + test('a read-only role is denied, not just missing the Save button', async ({ + page, + loginAs, + }) => { + await loginAs('orgProjectsReadOnly'); + await expectDenied(page, `/org/${org}/settings/roles-permissions/new`); + }); +}); + +test.describe('organization projects — real create mutation', () => { + test('org admin can create a project and see it in the list', async ({ page, loginAs }) => { + await loginAs('orgAdmin'); + const response = await page.request.post(`/org/${org}/settings/projects?/createProject`, { + form: { + organizationId: seed.primaryOrgId, + name: 'E2E Mutation Project', + slug: 'e2e-mutation-project', + status: 'active', + }, + }); + const body = await response.json(); + expect(body.type).not.toBe('failure'); + + await page.goto(`/org/${org}/settings/projects`); + await expect(page.getByText('E2E Mutation Project')).toBeVisible(); + }); + + test('a read-only org role gets a 403 on the same POST', async ({ page, loginAs }) => { + await loginAs('orgProjectsReadOnly'); + const response = await page.request.post(`/org/${org}/settings/projects?/createProject`, { + form: { + organizationId: seed.primaryOrgId, + name: 'Should Not Be Created', + slug: 'should-not-be-created-org', + status: 'active', + }, + }); + const body = await response.json(); + expect(body.type).toBe('failure'); + expect(body.status).toBe(403); + }); +}); diff --git a/e2e/specs/project-settings.spec.ts b/e2e/specs/project-settings.spec.ts new file mode 100644 index 0000000..03d3b4e --- /dev/null +++ b/e2e/specs/project-settings.spec.ts @@ -0,0 +1,184 @@ +import { test, expect } from '../fixtures/auth'; +import { expectAllowed, expectDenied } from '../fixtures/expect-access'; +import { getSeed } from '../fixtures/auth'; + +const seed = getSeed(); +const org = seed.primaryOrgSlug; +const project = seed.primaryProjectSlug; +const base = `/org/${org}/projects/${project}`; + +const settingsRoutes = [ + `${base}/settings/overview`, + `${base}/settings/access-control`, + `${base}/settings/roles-permissions`, + `${base}/settings/server-access-keys`, + `${base}/settings/audit`, +]; + +test('project admin sees every project settings resource', async ({ page, loginAs }) => { + await loginAs('projectAdmin'); + for (const route of settingsRoutes) { + await expectAllowed(page, route); + } +}); + +test('a project outsider (role on a different project) is denied everything here', async ({ + page, + loginAs, +}) => { + await loginAs('projectOutsider'); + for (const route of settingsRoutes) { + await expectDenied(page, route); + } +}); + +test.describe('project overview — read-only role (project:project:read only)', () => { + test('Danger Zone and Save are hidden, fields render as read-only text', async ({ + page, + loginAs, + }) => { + await loginAs('projectReadOnly'); + await page.goto(`${base}/settings/overview`); + await expect(page.getByRole('button', { name: 'Guardar cambios' })).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Zona de peligro' })).toHaveCount(0); + // the name field must not be an editable input when read-only + await expect(page.locator('input#edit-project-name')).toHaveCount(0); + }); +}); + +test.describe('project overview — project admin', () => { + test('Danger Zone and Save are visible, name field is editable', async ({ page, loginAs }) => { + await loginAs('projectAdmin'); + await page.goto(`${base}/settings/overview`); + await expect(page.getByRole('button', { name: 'Guardar cambios' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Zona de peligro' })).toBeVisible(); + await expect(page.locator('input#edit-project-name')).toBeVisible(); + }); +}); + +test.describe('server access keys — no delete permission', () => { + test('Rotate is visible, Revoke is not', async ({ page, loginAs }) => { + await loginAs('projectServerKeysNoDelete'); + await page.goto(`${base}/settings/server-access-keys`); + await expect(page.getByRole('button', { name: 'Crear key' })).toBeVisible(); + await expect(page.getByRole('button', { name: /rotar|regenerar/i }).first()).toBeVisible(); + await expect(page.getByRole('button', { name: /revocar|eliminar/i })).toHaveCount(0); + }); +}); + +test.describe('server access keys — no update permission', () => { + test('neither Rotate nor Revoke are visible', async ({ page, loginAs }) => { + await loginAs('projectServerKeysNoUpdate'); + await page.goto(`${base}/settings/server-access-keys`); + await expect(page.getByRole('button', { name: /rotar|regenerar/i })).toHaveCount(0); + await expect(page.getByRole('button', { name: /revocar|eliminar/i })).toHaveCount(0); + }); +}); + +test.describe('server access keys — real revoke mutation', () => { + // posted directly (not clicked through the UI): the modal's confirm button depends on + // client-side hydration, which is unrelated to what this test cares about — whether the + // server actually revokes the key for a role with delete permission, not whether the click + // wiring works. + test('a role with delete permission can actually revoke a key', async ({ + page, + loginAs, + }) => { + await loginAs('projectAdmin'); + await page.goto(`${base}/settings/server-access-keys`); + const before = await page.getByRole('button', { name: /revocar/i }).count(); + + const keyId = await page.locator('input[name="keyId"]').first().getAttribute('value'); + expect(keyId, 'expected at least one seeded key on the page').toBeTruthy(); + + const response = await page.request.post(`${base}/settings/server-access-keys?/revoke`, { + form: { keyId: keyId! }, + }); + const body = await response.json(); + expect(body.type).not.toBe('failure'); + + await page.reload(); + await expect(page.getByRole('button', { name: /revocar/i })).toHaveCount(before - 1); + }); +}); + +test.describe('project users/roles — read guard (no project:users:read / project:roles:read)', () => { + test('a project:project:read-only role is denied the user list', async ({ page, loginAs }) => { + await loginAs('projectReadOnly'); + await expectDenied(page, `${base}/settings/access-control`); + }); + + test('a project:project:read-only role is denied the role list and detail', async ({ + page, + loginAs, + }) => { + await loginAs('projectReadOnly'); + await expectDenied(page, `${base}/settings/roles-permissions`); + }); + + test('a project:project:read-only role is denied the "new role" form', async ({ + page, + loginAs, + }) => { + await loginAs('projectReadOnly'); + await expectDenied(page, `${base}/settings/roles-permissions/new`); + }); +}); + +test.describe('project users — role-change control (canUpdate)', () => { + test('a role without project:users:update sees a static label, not the dropdown', async ({ + page, + loginAs, + }) => { + await loginAs('projectUsersNoUpdate'); + await page.goto(`${base}/settings/access-control`); + // the role-change trigger is a
{$_('common.name')} {$_('common.slug')}
- + {role.name} diff --git a/src/lib/components/Users.svelte b/src/lib/components/Users.svelte index f157537..b7f429c 100644 --- a/src/lib/components/Users.svelte +++ b/src/lib/components/Users.svelte @@ -46,6 +46,8 @@ export let assignableUsers: AssignableUserRow[] = []; export let title = ''; export let description = ''; + export let canCreate = true; + export let canDelete = true; let users: AccessUserRow[] = initialUsers; let searchQuery = ''; @@ -122,7 +124,9 @@ const result = deserialize(await response.text()); if (result.type === 'failure' || result.type === 'error') { const data = result.type === 'failure' ? result.data : null; - throw new Error(typeof data?.error === 'string' ? data.error : $_('usersComponent.actionFailed')); + throw new Error( + typeof data?.error === 'string' ? data.error : $_('usersComponent.actionFailed'), + ); } await invalidateAll(); } @@ -156,7 +160,9 @@ userId: selectedUserId, }); addModalOpen = false; - flashSuccess(scope === 'project' ? $_('usersComponent.userAssigned') : $_('usersComponent.userCreated')); + flashSuccess( + scope === 'project' ? $_('usersComponent.userAssigned') : $_('usersComponent.userCreated'), + ); } catch (err: unknown) { addError = err instanceof Error ? err.message : $_('usersComponent.addFailed'); } finally { @@ -274,29 +280,35 @@
-

{title || $_('usersComponent.defaultTitle')}

-

{description || $_('usersComponent.defaultDescription')}

+

+ {title || $_('usersComponent.defaultTitle')} +

+

+ {description || $_('usersComponent.defaultDescription')} +

-
- {#if scope === 'organization'} + {#if canCreate} +
+ {#if scope === 'organization'} + + {/if} - {/if} - -
+
+ {/if}
@@ -393,7 +405,9 @@ disabled={savingAccessId === user.id || roles.length === 0} class="btn-secondary inline-flex min-w-44 items-center justify-between gap-2 rounded-md px-2.5 py-2 text-sm font-medium" > - {user.role?.name ?? $_('usersComponent.selectRole')} + {user.role?.name ?? $_('usersComponent.selectRole')} {#if openRoleMenuId === user.id} @@ -434,7 +448,7 @@
{formatDate(user.createdAt)}
- {#if user.status === 'invited'} + {#if user.status === 'invited' && canCreate} + {/if} + {#if canDelete} + {/if} -
-
- - {#if openRoleMenuId === user.id} -
+ - {/each} -
- {/if} -
+ {user.role?.name ?? $_('usersComponent.selectRole')} + + + {#if openRoleMenuId === user.id} +
+ {#each roles as role (role.id)} + + {/each} +
+ {/if} + + {:else} + {user.role?.name ?? $_('usersComponent.selectRole')} + {/if}
{ const result = await this.db - .with({ user: true, role: true, organization: true, project: true }) + .with({ user: true, role: true, organization: true, project: { organization: true } }) .select() .from(UserAccessEntity) .orderBy('createdAt', 'asc'); @@ -18,7 +18,7 @@ export class UserAccessRepository extends Repository { async findByUserId(userId: string): Promise { const result = await this.db - .with({ user: true, role: true, organization: true, project: true }) + .with({ user: true, role: true, organization: true, project: { organization: true } }) .select() .from(UserAccessEntity) .where({ userId }) @@ -28,7 +28,7 @@ export class UserAccessRepository extends Repository { async findById(id: string): Promise { const result = await this.db - .with({ user: true, role: true, organization: true, project: true }) + .with({ user: true, role: true, organization: true, project: { organization: true } }) .select() .from(UserAccessEntity) .where({ id }) diff --git a/src/routes/cluster-settings/access-control/+page.svelte b/src/routes/cluster-settings/access-control/+page.svelte index 5ad3c4d..4adee26 100644 --- a/src/routes/cluster-settings/access-control/+page.svelte +++ b/src/routes/cluster-settings/access-control/+page.svelte @@ -11,4 +11,7 @@ roles={data.roles} title={$_('clusterSettings.users.title')} description={$_('clusterSettings.users.description')} + canCreate={true} + canUpdate={true} + canDelete={true} /> diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts index 1c41e7c..e0c0acd 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.server.ts @@ -21,6 +21,16 @@ export async function load({ parent, locals }) { throw error(403, 'Forbidden'); } + const canCreate = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:create', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + const services = await codeReportService.listByProject(project.id); const servicesWithSeverity = await Promise.all( @@ -38,7 +48,7 @@ export async function load({ parent, locals }) { }), ); - return { services: servicesWithSeverity }; + return { services: servicesWithSeverity, canCreate }; } export const actions = { @@ -70,7 +80,7 @@ export const actions = { try { await codeReportService.createService({ - projectId: project.id, + project: params.slug, name, slug: slug || undefined, description: description || undefined, diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte index c0bbff2..5cc6463 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/+page.svelte @@ -32,6 +32,7 @@ export let data: { services: ServiceRow[]; project?: { slug?: string; organization?: { slug?: string | null } | null }; + canCreate: boolean; }; export let form: { success?: boolean; @@ -113,14 +114,16 @@ class="w-full rounded-full border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-sm text-slate-900 shadow-sm focus:border-slate-400 focus:outline-none" /> - + {#if data.canCreate} + + {/if} {#if filteredServices.length === 0} diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts index e0ddcf2..042e185 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.server.ts @@ -20,6 +20,19 @@ export async function load({ parent, params, locals }) { throw error(403, 'Forbidden'); } + const [canCreate, canDelete] = await Promise.all([ + cancanService.canSessionUser(locals.user, 'project:codereport:reports:create', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + cancanService.canSessionUser(locals.user, 'project:codereport:reports:delete', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), + ]); + try { const service = await codeReportService.getByProjectIdAndSlug(project.id, params.serviceSlug); const analyses = await codeReportAnalysisService.listByService(service.id); @@ -45,7 +58,15 @@ export async function load({ parent, params, locals }) { const riskWeights = await codeReportService.getRiskWeightsByProjectId(project.id); - return { service, latestAnalysis, latestByTool, analysisHistory, riskWeights }; + return { + service, + latestAnalysis, + latestByTool, + analysisHistory, + riskWeights, + canCreate, + canDelete, + }; } catch { throw error(404, 'Service not found'); } diff --git a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte index 6a0c00d..c1cec61 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/code-report/services/[serviceSlug]/+page.svelte @@ -12,6 +12,8 @@ analysisHistory: any[]; riskWeights: { critical: number; high: number; medium: number; low: number }; project?: { slug?: string; organization?: { slug?: string | null } | null }; + canCreate: boolean; + canDelete: boolean; }; export let form: { error?: string; @@ -45,16 +47,18 @@ > {$_('codeReport.serviceDetail.viewHistory')} - + {#if data.canDelete} + + {/if} diff --git a/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.server.ts b/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.server.ts index f686f50..e956559 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.server.ts @@ -1,3 +1,5 @@ +import { error, fail } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; import { projectService } from '$modules/projects'; const DEFAULT_CODE_REPORT_TOOLS = [ @@ -32,9 +34,33 @@ const DEFAULT_CODE_REPORT_TOOLS = [ }, ]; -export async function load({ params }) { +export async function load({ params, locals }) { const project = await projectService.getProjectBySlug(params.slug); + const canRead = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:read', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + const canUpdate = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:update', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + const codeReportSettings = project.settings?.['code-report'] || {}; const persistedTools = Array.isArray(codeReportSettings.tools) ? codeReportSettings.tools : []; const persistedById = new Map( @@ -53,16 +79,31 @@ export async function load({ params }) { return { tools, + canUpdate, }; } export const actions = { - updateTools: async ({ request, params }) => { + updateTools: async ({ request, params, locals }) => { + const project = await projectService.getProjectBySlug(params.slug); + + const canUpdate = await cancanService.canSessionUser( + locals.user, + 'project:codereport:reports:update', + { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }, + ); + + if (!canUpdate) { + return fail(403, { error: 'Forbidden' }); + } + const data = await request.formData(); const enabledToolIds = data.getAll('tools'); - const project = await projectService.getProjectBySlug(params.slug); - const currentSettings = project.settings || {}; const codeReportSettings = currentSettings['code-report'] || {}; const enabledSet = new Set(enabledToolIds.map((id) => String(id))); diff --git a/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.svelte b/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.svelte index a262750..8f70c94 100644 --- a/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/code-report/settings/tools/+page.svelte @@ -15,6 +15,7 @@ }; $: tools = data.tools as AnalysisTool[]; + $: canUpdate = data.canUpdate; let localTools: AnalysisTool[] = []; $: localTools = tools.map((tool: AnalysisTool) => ({ ...tool })); @@ -50,7 +51,7 @@ {#each localTools as tool}
- +
-
- -
+ {#if canUpdate} +
+ +
+ {/if}
diff --git a/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.server.ts index f3e5f67..5117ab1 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import type { AuthenticatedUser } from '$modules/auth/domain/entities'; import { cancanService, roleService, userAccessService } from '$modules/auth'; import { projectService } from '$modules/projects'; @@ -27,7 +27,18 @@ async function canUpdateProjectUsers( export async function load({ parent, locals }) { const { project } = await parent(); - const [users, roles, assignableUsers, canCreate, canDelete] = await Promise.all([ + + const canRead = await cancanService.canSessionUser(locals.user, 'project:users:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + const [users, roles, assignableUsers, canCreate, canUpdate, canDelete] = await Promise.all([ userAccessService.listUsers('project', project.id), roleService.listRoles('project', project.id), userAccessService.listAssignableUsers(), @@ -36,13 +47,18 @@ export async function load({ parent, locals }) { projectId: project.id, organizationId: project.organization?.id, }), + cancanService.canSessionUser(locals.user, 'project:users:update', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }), cancanService.canSessionUser(locals.user, 'project:users:delete', { scope: 'project', projectId: project.id, organizationId: project.organization?.id, }), ]); - return { project, users, roles, assignableUsers, canCreate, canDelete }; + return { project, users, roles, assignableUsers, canCreate, canUpdate, canDelete }; } export const actions = { diff --git a/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.svelte index bb3004f..9b1aa4f 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/access-control/+page.svelte @@ -8,6 +8,7 @@ assignableUsers: any[]; project: { name: string }; canCreate: boolean; + canUpdate: boolean; canDelete: boolean; }; @@ -20,5 +21,6 @@ title={$_('projectSettings.users.title')} description={`${$_('projectSettings.users.descriptionPrefix')} ${data.project.name}${$_('projectSettings.users.descriptionSuffix')}`} canCreate={data.canCreate} + canUpdate={data.canUpdate} canDelete={data.canDelete} /> diff --git a/src/routes/org/[org]/projects/[slug]/settings/audit/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/audit/+page.server.ts new file mode 100644 index 0000000..e2aa3bb --- /dev/null +++ b/src/routes/org/[org]/projects/[slug]/settings/audit/+page.server.ts @@ -0,0 +1,18 @@ +import { error } from '@sveltejs/kit'; +import { cancanService } from '$modules/auth'; + +export async function load({ parent, locals }) { + const { project } = await parent(); + + const canRead = await cancanService.canSessionUser(locals.user, 'project:audit:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + + return {}; +} diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts index 59800bd..67a91e5 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { cancanService, roleService } from '$modules/auth'; import { projectService } from '$modules/projects'; @@ -29,6 +29,17 @@ async function canManageProjectRole( export async function load({ parent, locals }) { const { project } = await parent(); + + const canRead = await cancanService.canSessionUser(locals.user, 'project:roles:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + const [roles, canCreate] = await Promise.all([ roleService.listRoles('project', project.id), cancanService.canSessionUser(locals.user, 'project:roles:create', { diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts index 53e2685..1d1e2ed 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/[id]/+page.server.ts @@ -31,6 +31,17 @@ async function canManageProjectRole( export async function load({ parent, params, locals }) { const { project } = await parent(); + + const canRead = await cancanService.canSessionUser(locals.user, 'project:roles:read', { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + + if (!canRead) { + throw error(403, 'Forbidden'); + } + const [roles, canUpdate, canDelete] = await Promise.all([ roleService.listRoles('project', project.id), cancanService.canSessionUser(locals.user, 'project:roles:update', { diff --git a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts index 0099790..6cab3f2 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/roles-permissions/new/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { cancanService, roleService } from '$modules/auth'; import { projectService } from '$modules/projects'; @@ -36,6 +36,11 @@ export async function load({ parent, locals }) { projectId: project.id, organizationId: project.organization?.id, }); + + if (!canCreate) { + throw error(403, 'Forbidden'); + } + return { project, canCreate }; } diff --git a/src/routes/org/[org]/settings/access-control/+page.server.ts b/src/routes/org/[org]/settings/access-control/+page.server.ts index 1d09b39..8c915e3 100644 --- a/src/routes/org/[org]/settings/access-control/+page.server.ts +++ b/src/routes/org/[org]/settings/access-control/+page.server.ts @@ -18,19 +18,23 @@ export async function load({ parent, locals }) { throw error(403, 'Forbidden'); } - const [users, roles, canCreate, canDelete] = await Promise.all([ + const [users, roles, canCreate, canUpdate, canDelete] = await Promise.all([ userAccessService.listUsers('organization', organization.id), roleService.listRoles('organization', organization.id), cancanService.canSessionUser(locals.user, 'organization:users:create', { scope: 'organization', organizationId: organization.id, }), + cancanService.canSessionUser(locals.user, 'organization:users:update', { + scope: 'organization', + organizationId: organization.id, + }), cancanService.canSessionUser(locals.user, 'organization:users:delete', { scope: 'organization', organizationId: organization.id, }), ]); - return { users, roles, canCreate, canDelete }; + return { users, roles, canCreate, canUpdate, canDelete }; } export const actions = { diff --git a/src/routes/org/[org]/settings/access-control/+page.svelte b/src/routes/org/[org]/settings/access-control/+page.svelte index 261d97f..f84f1a9 100644 --- a/src/routes/org/[org]/settings/access-control/+page.svelte +++ b/src/routes/org/[org]/settings/access-control/+page.svelte @@ -2,7 +2,13 @@ import Users from '$lib/components/Users.svelte'; import { _ } from 'svelte-i18n'; - export let data: { users: any[]; roles: any[]; canCreate: boolean; canDelete: boolean }; + export let data: { + users: any[]; + roles: any[]; + canCreate: boolean; + canUpdate: boolean; + canDelete: boolean; + }; diff --git a/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts b/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts index a1bb942..dc8e365 100644 --- a/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts +++ b/src/routes/org/[org]/settings/roles-permissions/new/+page.server.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; +import { error, fail } from '@sveltejs/kit'; import { cancanService, roleService } from '$modules/auth'; import { organizationService } from '$modules/organization'; @@ -21,6 +21,11 @@ export async function load({ parent, locals }) { scope: 'organization', organizationId: organization.id, }); + + if (!canCreate) { + throw error(403, 'Forbidden'); + } + return { organization, canCreate }; } diff --git a/tsconfig.json b/tsconfig.json index 5b531fb..c122e1f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,13 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "paths": { + "$modules": ["./src/modules"], + "$modules/*": ["./src/modules/*"], + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"], + "$env/dynamic/private": ["./test/env-dynamic-private.ts"] + } } }