From c1ef84500efec434c51dea1ec8a0e0b46450bad2 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 18 Jul 2026 23:21:36 +0200 Subject: [PATCH 01/15] feat(auth): restrict product writes to admins (Access Control v2, Block 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cookie-authenticated product writes now require admin, closing the gap where any JIT-provisioned modgud user could create/update/delete products. - ApiKeyFilter: cookie path requires AdminCheck.IsAdmin; non-admin cookie session → 403. Bearer API-key path (CI/CD machine track) unchanged. - SPA: /admin/products route gated with admin meta; Products sidebar link shown only to admins; Dashboard product affordances gated so non-admins don't dead-end into an admin-only surface. - Tests: split CookieAuth_WorksForProductWrites into CookieAuth_AdminCanWriteProducts (201) and CookieAuth_NonAdmin_CannotWriteProducts (403), exercising the real RBAC path. Concept: Atlas shelf/concept-access-control-v2 (enforcement points table). Co-Authored-By: Claude Fable 5 --- .../src/layouts/AdminLayout.vue | 1 + src/Cocoar.Shelf.Client/src/router/index.ts | 1 + .../src/views/DashboardView.vue | 20 +++++++-- src/Cocoar.Shelf/Endpoints/ApiKeyFilter.cs | 41 ++++++++++++------- .../Integration/AuthApiTests.cs | 19 +++++++-- 5 files changed, 61 insertions(+), 21 deletions(-) diff --git a/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue b/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue index fc0b4ac..0b03d92 100644 --- a/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue +++ b/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue @@ -116,6 +116,7 @@ function logout() { @click="router.push('/admin')" /> import('@/views/products/ProductListView.vue'), meta: { + admin: true, routedFragments: [ { type: 'modal', diff --git a/src/Cocoar.Shelf.Client/src/views/DashboardView.vue b/src/Cocoar.Shelf.Client/src/views/DashboardView.vue index 4d49ba0..cc7475d 100644 --- a/src/Cocoar.Shelf.Client/src/views/DashboardView.vue +++ b/src/Cocoar.Shelf.Client/src/views/DashboardView.vue @@ -1,7 +1,11 @@ @@ -447,6 +463,21 @@ async function onDeleteVersion(version: string) { .mt-2 { margin-top: 8px; } .mb-3 { margin-bottom: 12px; } +.access-block { + border-top: 1px solid var(--coar-border-neutral-tertiary); + padding-top: 12px; +} + +.access-desc { + margin: 8px 0 0 26px; +} + +.access-desc a { + color: var(--coar-text-accent-primary); + text-decoration: none; +} +.access-desc a:hover { text-decoration: underline; } + .tag-input-row { display: flex; gap: 8px; diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductListView.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductListView.vue index 5b4de1a..9d5c2e3 100644 --- a/src/Cocoar.Shelf.Client/src/views/products/ProductListView.vue +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductListView.vue @@ -29,7 +29,7 @@ ui.set((ctx) => { const rowData = computed(() => productsStore.items); const builder = CoarGridBuilder.create() - .persistColumnState('shelf-products-v2') + .persistColumnState('shelf-products-v3') .option('getRowId', (p: any) => p.data.name) .rowDataRef(rowData) .searchHighlight() @@ -53,6 +53,8 @@ const builder = CoarGridBuilder.create() (col: any) => col.field('displayName').header('Display Name').flex(1).option('minWidth', 180), (col: any) => col.field('description').header('Description').flex(2).option('minWidth', 200), (col: any) => col.field('visibility').header('Visibility').width(110).option('minWidth', 100), + (col: any) => col.field('restricted').header('Restricted').width(110).option('minWidth', 95) + .option('valueGetter', (p: any) => (p.data?.restricted ? 'restricted' : '')), (col: any) => col.field('latest').header('Latest').width(110).option('minWidth', 90), (col: any) => col.field('versions').header('Versions').width(100).option('minWidth', 95) .option('valueGetter', (p: any) => p.data?.versions?.length ?? 0), diff --git a/src/Cocoar.Shelf/Endpoints/UserEndpoints.cs b/src/Cocoar.Shelf/Endpoints/UserEndpoints.cs index 6e0c048..566984c 100644 --- a/src/Cocoar.Shelf/Endpoints/UserEndpoints.cs +++ b/src/Cocoar.Shelf/Endpoints/UserEndpoints.cs @@ -33,6 +33,9 @@ private static async Task ListUsers( .OrderBy(u => u.UserName) .ToListAsync(ct); + // Group memberships per user (email ∪ materialized auto ids) for the memberships column. + var groups = (await session.Query().ToListAsync(ct)).Where(g => !g.IsDeleted).ToList(); + return Results.Ok(users.Select(u => new { u.Id, @@ -40,10 +43,20 @@ private static async Task ListUsers( u.DisplayName, u.Email, u.IsActive, - u.CreatedAt + u.CreatedAt, + Groups = groups + .Where(g => IsMember(g, u)) + .Select(g => g.Name) + .Order() + .ToArray(), + IsAdminViaGroup = groups.Any(g => g.IsAdminGroup && IsMember(g, u)), })); } + private static bool IsMember(Group group, UserDocument user) => + (user.Email is not null && group.MemberEmails.Any(m => string.Equals(m, user.Email, StringComparison.OrdinalIgnoreCase))) + || group.AutoMemberUserIds.Contains(user.Id); + private static async Task SetActive( Guid id, SetActiveRequest request, From 988f6db0cc7bf546ceeda0bbe7a6085ad0b8340a Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 19 Jul 2026 00:24:31 +0200 Subject: [PATCH 05/15] feat(ui): use CoarScriptEditor (Monaco) for the membership script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the plain textarea with the Monaco-based CoarScriptEditor from the separate @cocoar/vue-script-editor package (2.17.1 — not part of vue-ui): TypeScript language mode, scriptMode (no body diagnostics), and a hidden preamble declaring `user` so the editor gives IntelliSense on user.email / user.permissions / user.claims. CSS wired via @import "@cocoar/vue-script-editor/styles". Verified locally against the dev stack (modgud OIDC login) with chrome-devtools: groups page + nav gating, create modal + tab/mode switching, editor renders, group create → grid, JsEval dry-run against the live backend (match true/false), product Restricted toggle + grid column. Fixed an editor-height collapse (height needs a unit: "180px", not "180"). Co-Authored-By: Claude Fable 5 --- src/Cocoar.Shelf.Client/package.json | 1 + src/Cocoar.Shelf.Client/pnpm-lock.yaml | 42 +++++++++++++++++++ src/Cocoar.Shelf.Client/src/styles.css | 1 + .../src/views/groups/GroupFormModal.vue | 35 ++++++---------- 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/src/Cocoar.Shelf.Client/package.json b/src/Cocoar.Shelf.Client/package.json index 95f1b5d..a3028e7 100644 --- a/src/Cocoar.Shelf.Client/package.json +++ b/src/Cocoar.Shelf.Client/package.json @@ -11,6 +11,7 @@ "dependencies": { "@cocoar/vue-data-grid": "2.17.1", "@cocoar/vue-fragment-parser": "2.17.1", + "@cocoar/vue-script-editor": "2.17.1", "@cocoar/vue-ui": "2.17.1", "overlayscrollbars": "^2.16.0", "pinia": "^4.0.2", diff --git a/src/Cocoar.Shelf.Client/pnpm-lock.yaml b/src/Cocoar.Shelf.Client/pnpm-lock.yaml index 03e5eae..9fadccf 100644 --- a/src/Cocoar.Shelf.Client/pnpm-lock.yaml +++ b/src/Cocoar.Shelf.Client/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@cocoar/vue-fragment-parser': specifier: 2.17.1 version: 2.17.1(@cocoar/vue-ui@2.17.1(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.5)(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)))(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.5)(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)) + '@cocoar/vue-script-editor': + specifier: 2.17.1 + version: 2.17.1(monaco-editor@0.55.1)(vue@3.5.40(typescript@6.0.3)) '@cocoar/vue-ui': specifier: 2.17.1 version: 2.17.1(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.5)(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)) @@ -118,6 +121,12 @@ packages: peerDependencies: vue: ^3.5.0 + '@cocoar/vue-script-editor@2.17.1': + resolution: {integrity: sha512-untRHzKtOnJpD57IObaE0NOk5cGtvXtjnyxKPflPS9dnfhpaVg2d1UEkj5TLbwaZgFh/Fzb/AkW1po8xIQHaPA==} + peerDependencies: + monaco-editor: ^0.55.1 + vue: ^3.5.0 + '@cocoar/vue-ui@2.17.1': resolution: {integrity: sha512-VYOoYWTKjDfvU0cS7GtvYbXXFSutFAZLswQq4SBIwsqwytKk7iTtCgCT5+XBbnmSIpxoiPTO7nMxpJuouMppMQ==} peerDependencies: @@ -374,6 +383,9 @@ packages: '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@vitejs/plugin-vue@6.0.8': resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} engines: {node: ^20.19.0 || >=22.12.0} @@ -485,6 +497,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + enhanced-resolve@5.24.2: resolution: {integrity: sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==} engines: {node: '>=10.13.0'} @@ -617,9 +632,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -901,6 +924,11 @@ snapshots: dependencies: vue: 3.5.40(typescript@6.0.3) + '@cocoar/vue-script-editor@2.17.1(monaco-editor@0.55.1)(vue@3.5.40(typescript@6.0.3))': + dependencies: + monaco-editor: 0.55.1 + vue: 3.5.40(typescript@6.0.3) + '@cocoar/vue-ui@2.17.1(vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.5)(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3))': dependencies: '@cocoar/vue-localization': 2.17.1(vue@3.5.40(typescript@6.0.3)) @@ -1108,6 +1136,9 @@ snapshots: '@types/jsesc@2.5.1': {} + '@types/trusted-types@2.0.7': + optional: true + '@vitejs/plugin-vue@6.0.8(vite@8.1.5(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -1253,6 +1284,10 @@ snapshots: detect-libc@2.1.2: {} + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + enhanced-resolve@5.24.2: dependencies: graceful-fs: 4.2.11 @@ -1346,6 +1381,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@14.0.0: {} + mlly@1.8.2: dependencies: acorn: 8.17.0 @@ -1353,6 +1390,11 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + muggle-string@0.4.1: {} nanoid@3.3.16: {} diff --git a/src/Cocoar.Shelf.Client/src/styles.css b/src/Cocoar.Shelf.Client/src/styles.css index a275e06..949a6f4 100644 --- a/src/Cocoar.Shelf.Client/src/styles.css +++ b/src/Cocoar.Shelf.Client/src/styles.css @@ -1,6 +1,7 @@ @import "tailwindcss" layer(tailwind); @import "@cocoar/vue-ui/styles"; @import "@cocoar/vue-data-grid/styles"; +@import "@cocoar/vue-script-editor/styles"; :root { /* One accent everywhere: the Cocoar brand blue the vue-ui theme's primary diff --git a/src/Cocoar.Shelf.Client/src/views/groups/GroupFormModal.vue b/src/Cocoar.Shelf.Client/src/views/groups/GroupFormModal.vue index 7a0a437..da77000 100644 --- a/src/Cocoar.Shelf.Client/src/views/groups/GroupFormModal.vue +++ b/src/Cocoar.Shelf.Client/src/views/groups/GroupFormModal.vue @@ -4,6 +4,7 @@ import { CoarTextInput, CoarSelect, CoarCheckbox, CoarMultiSelect, CoarNote, CoarButton, CoarFormField, CoarTabGroup, CoarTab, CoarTable, CoarTag, } from '@cocoar/vue-ui'; +import { CoarScriptEditor } from '@cocoar/vue-script-editor'; import ModalLayout from '@/components/ModalLayout.vue'; import { useGroupsStore } from '@/stores/groups.store'; import { useProductsStore } from '@/stores/products.store'; @@ -50,6 +51,10 @@ const testResult = ref<{ matched: boolean; error: string | null; email: string | const isAuto = computed(() => form.value.membershipMode === 'Auto'); +// Hidden type context so the Monaco editor gives IntelliSense on `user` without diagnostics noise. +const scriptPreamble = + 'declare const user: { email: string; permissions: string[]; claims: Record };'; + const productOptions = computed(() => productsStore.items.map(p => ({ value: p.name, label: p.displayName || p.name }))); @@ -247,13 +252,15 @@ async function save() { user.email, user.permissions, user.claims. Evaluated at each login and on save; a broken script simply matches no one.

- + /> Last evaluation error: {{ lastError }} @@ -369,24 +376,6 @@ async function save() { border-radius: 4px; } -.script-editor { - width: 100%; - font-family: var(--coar-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - font-size: 0.85rem; - line-height: 1.5; - padding: 10px 12px; - border: 1px solid var(--coar-border-neutral-tertiary); - border-radius: var(--coar-radius-m, 4px); - background: var(--coar-background-neutral-primary); - color: var(--coar-text-neutral-primary); - resize: vertical; -} - -.script-editor:focus { - outline: none; - border-color: var(--coar-text-accent-primary); -} - .mt-2 { margin-top: 8px; } .mb-3 { margin-bottom: 12px; } From 7bd32749d245f8b765be291f01a0d22d04092c67 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 19 Jul 2026 05:52:43 +0200 Subject: [PATCH 06/15] refactor(auth): product-side principal grants (groups + users as IPrincipal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flips the read-grant direction to the product side, per review: instead of a group listing its products, a restricted product lists the principals that may read it. Both Group and UserDocument now implement IPrincipal, so either can be assigned — matching timetodo's Principal model. - IPrincipal (Group + UserDocument) + PrincipalKind + PrincipalRef value type (group by id, user by email — email lets a grant be staged before first login). - ProductConfig.ReadPrincipals replaces Group.ReadProducts. - AccessResolver: grants now carry (isAdmin, groupIds, email); CanRead(product) tests the product's ReadPrincipals against them — no group ReadProducts scan. - GET /_api/principals: assignable groups + users for the product access picker. - Product create/update accept + expose ReadPrincipals; group upsert drops ReadProducts. Enum JSON as strings (PrincipalKind → "Group"/"User"). - Tests: grants moved product-side; added RestrictedDocs_DirectUserGrant_IsServed. Full suite 124/124; format gates clean. Co-Authored-By: Claude Fable 5 --- src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs | 23 +++++++--- src/Cocoar.Shelf/Endpoints/GroupEndpoints.cs | 5 --- .../Endpoints/PrincipalEndpoints.cs | 36 +++++++++++++++ .../Middleware/DocsRoutingMiddleware.cs | 4 +- src/Cocoar.Shelf/Models/Group.cs | 9 ++-- src/Cocoar.Shelf/Models/Principal.cs | 33 ++++++++++++++ src/Cocoar.Shelf/Models/ProductConfig.cs | 6 +++ src/Cocoar.Shelf/Models/ProductRequests.cs | 4 +- src/Cocoar.Shelf/Models/UserDocument.cs | 7 ++- src/Cocoar.Shelf/Program.cs | 5 +++ .../Services/Access/AccessResolver.cs | 7 ++- .../Services/Access/IAccessResolver.cs | 30 +++++++++---- .../Integration/AccessControlTests.cs | 44 +++++++++++++------ 13 files changed, 168 insertions(+), 45 deletions(-) create mode 100644 src/Cocoar.Shelf/Endpoints/PrincipalEndpoints.cs create mode 100644 src/Cocoar.Shelf/Models/Principal.cs diff --git a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs index 8a77922..1c8df79 100644 --- a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs +++ b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs @@ -20,6 +20,7 @@ public static WebApplication MapApiEndpoints(this WebApplication app, ShelfOptio api.MapUserEndpoints(); api.MapSettingsEndpoints(); api.MapGroupEndpoints(); + api.MapPrincipalEndpoints(); // Test-only sign-in seam — mapped exclusively for the integration test host. if (options.TestAuth) @@ -66,7 +67,7 @@ private static async Task GetProducts( var grants = await accessResolver.ResolveAsync(httpContext.User); var products = (await configService.GetAllAsync()) - .Where(config => !config.Restricted || grants.CanRead(config.Name)) + .Where(config => !config.Restricted || grants.CanRead(config)) .Select(config => { var manifest = manifestService.GetManifest(config.Name); @@ -78,6 +79,7 @@ private static async Task GetProducts( config.Source, config.Visibility, config.Restricted, + config.ReadPrincipals, config.Tags, config.ShowWhenEmpty, HasApiKey = !string.IsNullOrEmpty(config.ApiKey), @@ -108,7 +110,7 @@ private static async Task GetVersions( { var config = await configService.GetConfigAsync(product); // Restricted + no grant → 404, indistinguishable from not-registered (no existence leak). - if (config == null || (config.Restricted && !(await accessResolver.ResolveAsync(httpContext.User)).CanRead(product))) + if (config == null || (config.Restricted && !(await accessResolver.ResolveAsync(httpContext.User)).CanRead(config))) { LogProductNotRegistered(logger, product); return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); @@ -143,7 +145,7 @@ private static async Task GetProduct( { var config = await configService.GetConfigAsync(product); // Restricted + no grant → 404, indistinguishable from not-registered (no existence leak). - if (config == null || (config.Restricted && !(await accessResolver.ResolveAsync(httpContext.User)).CanRead(product))) + if (config == null || (config.Restricted && !(await accessResolver.ResolveAsync(httpContext.User)).CanRead(config))) { LogProductNotRegistered(logger, product); return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); @@ -158,6 +160,7 @@ private static async Task GetProduct( config.Source, config.Visibility, config.Restricted, + config.ReadPrincipals, config.Tags, config.ShowWhenEmpty, HasApiKey = !string.IsNullOrEmpty(config.ApiKey), @@ -339,7 +342,8 @@ private static async Task CreateProduct( Tags = NormalizeTags(request.Tags), ShowWhenEmpty = request.ShowWhenEmpty ?? false, ApiKey = request.ApiKey, - Restricted = request.Restricted ?? false + Restricted = request.Restricted ?? false, + ReadPrincipals = NormalizePrincipals(request.ReadPrincipals) }; await configService.CreateAsync(config); @@ -379,7 +383,8 @@ private static async Task UpdateProduct( Tags = request.Tags != null ? NormalizeTags(request.Tags) : existing.Tags, ShowWhenEmpty = request.ShowWhenEmpty ?? existing.ShowWhenEmpty, ApiKey = request.ApiKey ?? existing.ApiKey, - Restricted = request.Restricted ?? existing.Restricted + Restricted = request.Restricted ?? existing.Restricted, + ReadPrincipals = request.ReadPrincipals != null ? NormalizePrincipals(request.ReadPrincipals) : existing.ReadPrincipals }; await configService.UpdateAsync(config); @@ -531,4 +536,12 @@ private static async Task DeleteVersion( private static IReadOnlyList NormalizeTags(IReadOnlyList? tags) => tags == null ? [] : tags.Select(t => t.Trim()).Where(t => t.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList(); + + // Trim, drop blanks, dedupe (user emails case-insensitively). + private static IReadOnlyList NormalizePrincipals(IReadOnlyList? principals) => + principals == null ? [] : principals + .Where(p => !string.IsNullOrWhiteSpace(p.Id)) + .Select(p => new PrincipalRef(p.Kind, p.Id.Trim())) + .DistinctBy(p => (p.Kind, p.Kind == PrincipalKind.User ? p.Id.ToLowerInvariant() : p.Id)) + .ToList(); } diff --git a/src/Cocoar.Shelf/Endpoints/GroupEndpoints.cs b/src/Cocoar.Shelf/Endpoints/GroupEndpoints.cs index 89b0e39..88e08e3 100644 --- a/src/Cocoar.Shelf/Endpoints/GroupEndpoints.cs +++ b/src/Cocoar.Shelf/Endpoints/GroupEndpoints.cs @@ -11,7 +11,6 @@ public record GroupUpsertRequest( string? MembershipMode, IReadOnlyList? MemberEmails, string? MembershipScript, - IReadOnlyList? ReadProducts, bool? IsAdminGroup); /// Dry-run request: evaluate a membership script against one user (by id or email). @@ -64,7 +63,6 @@ private static async Task GetGroup(Guid id, IGroupService groupService, MembershipMode = group.MembershipMode.ToString(), group.MemberEmails, group.MembershipScript, - group.ReadProducts, group.IsAdminGroup, group.MembershipLastError, AutoMembers = autoMembers, @@ -158,7 +156,6 @@ private static async Task TestScript( MembershipMode = g.MembershipMode.ToString(), g.MemberEmails, g.MembershipScript, - g.ReadProducts, g.IsAdminGroup, AutoMemberCount = g.AutoMemberUserIds.Count, g.MembershipLastError, @@ -171,8 +168,6 @@ private static void Apply(Group group, GroupUpsertRequest request, MembershipMod group.MembershipMode = mode; group.MemberEmails = NormalizeEmails(request.MemberEmails); group.MembershipScript = mode == Models.MembershipMode.Auto ? request.MembershipScript : null; - group.ReadProducts = request.ReadProducts?.Where(p => !string.IsNullOrWhiteSpace(p)) - .Select(p => p.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).Order().ToList() ?? []; group.IsAdminGroup = request.IsAdminGroup ?? false; } diff --git a/src/Cocoar.Shelf/Endpoints/PrincipalEndpoints.cs b/src/Cocoar.Shelf/Endpoints/PrincipalEndpoints.cs new file mode 100644 index 0000000..732c301 --- /dev/null +++ b/src/Cocoar.Shelf/Endpoints/PrincipalEndpoints.cs @@ -0,0 +1,36 @@ +using Cocoar.Shelf.Models; +using Cocoar.Shelf.Services; +using Marten; + +namespace Cocoar.Shelf.Endpoints; + +/// +/// Admin lookup of assignable principals (groups + users) for the product access picker — both are +/// , so they come back as one kind-tagged list. +/// +public static class PrincipalEndpoints +{ + public static RouteGroupBuilder MapPrincipalEndpoints(this RouteGroupBuilder api) + { + api.MapGet("/principals", GetPrincipals).RequireAuthorization("Admin"); + return api; + } + + private static async Task GetPrincipals( + IGroupService groupService, IQuerySession session, CancellationToken ct) + { + var groups = await groupService.GetAllAsync(); + var users = await session.Query().OrderBy(u => u.UserName).ToListAsync(ct); + + var principals = groups.Cast() + .Concat(users.Where(u => !string.IsNullOrWhiteSpace(u.Email)).Cast()) + .Select(p => new + { + Kind = p.PrincipalKind.ToString(), + Id = p.PrincipalId, + DisplayName = p.PrincipalDisplayName, + }); + + return Results.Ok(principals); + } +} diff --git a/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs b/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs index e7f2874..f675305 100644 --- a/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs +++ b/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs @@ -71,7 +71,7 @@ public async Task InvokeAsync(HttpContext context) // Access control v2: restricted products are gated before anything is served (HTML and assets). var productConfig = await _productConfig.GetConfigAsync(product); - if (productConfig?.Restricted == true && !await HasReadAccessAsync(context, product)) + if (productConfig?.Restricted == true && !await HasReadAccessAsync(context, productConfig)) { DenyRestricted(context); return; @@ -191,7 +191,7 @@ private void RecordAccess(HttpContext context, string product, string version, s }); } - private static async Task HasReadAccessAsync(HttpContext context, string product) + private static async Task HasReadAccessAsync(HttpContext context, ProductConfig product) { var resolver = context.RequestServices.GetRequiredService(); var grants = await resolver.ResolveAsync(context.User); diff --git a/src/Cocoar.Shelf/Models/Group.cs b/src/Cocoar.Shelf/Models/Group.cs index 3f3b44f..8791327 100644 --- a/src/Cocoar.Shelf/Models/Group.cs +++ b/src/Cocoar.Shelf/Models/Group.cs @@ -21,7 +21,7 @@ public enum MembershipMode /// persisted login claims snapshot. Email-based explicit membership lets grants be staged before a /// user's first login (the local is JIT-provisioned). /// -public class Group +public class Group : IPrincipal { public Guid Id { get; set; } @@ -29,6 +29,10 @@ public class Group public string? Description { get; set; } + PrincipalKind IPrincipal.PrincipalKind => PrincipalKind.Group; + string IPrincipal.PrincipalId => Id.ToString(); + string IPrincipal.PrincipalDisplayName => Name; + public MembershipMode MembershipMode { get; set; } = MembershipMode.Manual; /// Explicit members, by email (case-insensitive). Applies in both membership modes; @@ -39,9 +43,6 @@ public class Group /// snapshot returning a bool. Ignored in mode. public string? MembershipScript { get; set; } - /// Product names this group grants read access to (restricted products). - public IReadOnlyList ReadProducts { get; set; } = []; - /// /// Materialized ids of users matched by in , recomputed on triggers (login of a user, group save, manual diff --git a/src/Cocoar.Shelf/Models/Principal.cs b/src/Cocoar.Shelf/Models/Principal.cs new file mode 100644 index 0000000..ee3dd29 --- /dev/null +++ b/src/Cocoar.Shelf/Models/Principal.cs @@ -0,0 +1,33 @@ +namespace Cocoar.Shelf.Models; + +/// The kind of principal a grant refers to (Access Control v2). +public enum PrincipalKind +{ + Group = 0, + User = 1, +} + +/// +/// Something a product's read access can be granted to. Both and +/// are principals, so either can be assigned to a product. +/// +public interface IPrincipal +{ + PrincipalKind PrincipalKind { get; } + + /// Stable reference id: a group's Id (GUID string) or a user's email. Email keys + /// users so a grant can be staged before the user's first (JIT-provisioning) login. + string PrincipalId { get; } + + string PrincipalDisplayName { get; } +} + +/// +/// A reference to a principal, stored on a product's read-access list. Kind-tagged so groups (by id) +/// and users (by email) share one list — the product-side, principal-oriented grant. +/// +public sealed record PrincipalRef(PrincipalKind Kind, string Id) +{ + public static PrincipalRef ForGroup(Guid id) => new(PrincipalKind.Group, id.ToString()); + public static PrincipalRef ForUser(string email) => new(PrincipalKind.User, email); +} diff --git a/src/Cocoar.Shelf/Models/ProductConfig.cs b/src/Cocoar.Shelf/Models/ProductConfig.cs index 886b103..a2b87df 100644 --- a/src/Cocoar.Shelf/Models/ProductConfig.cs +++ b/src/Cocoar.Shelf/Models/ProductConfig.cs @@ -20,6 +20,12 @@ public class ProductConfig /// public bool Restricted { get; set; } + /// + /// Principals (groups and/or individual users) granted read access when . + /// Product-side, principal-oriented grant — you pick who may read here, next to the toggle. + /// + public IReadOnlyList ReadPrincipals { get; set; } = []; + public IReadOnlyList Tags { get; set; } = []; public bool ShowWhenEmpty { get; set; } diff --git a/src/Cocoar.Shelf/Models/ProductRequests.cs b/src/Cocoar.Shelf/Models/ProductRequests.cs index e49d846..057ef54 100644 --- a/src/Cocoar.Shelf/Models/ProductRequests.cs +++ b/src/Cocoar.Shelf/Models/ProductRequests.cs @@ -1,5 +1,5 @@ namespace Cocoar.Shelf.Models; -public record CreateProductRequest(string Name, string? DisplayName, string? Description, string? Source, string? Visibility, IReadOnlyList? Tags, bool? ShowWhenEmpty, string? ApiKey, bool? Restricted = null); +public record CreateProductRequest(string Name, string? DisplayName, string? Description, string? Source, string? Visibility, IReadOnlyList? Tags, bool? ShowWhenEmpty, string? ApiKey, bool? Restricted = null, IReadOnlyList? ReadPrincipals = null); -public record UpdateProductRequest(string? DisplayName, string? Description, string? Source, string? Visibility, IReadOnlyList? Tags, bool? ShowWhenEmpty, string? ApiKey, bool? Restricted = null); +public record UpdateProductRequest(string? DisplayName, string? Description, string? Source, string? Visibility, IReadOnlyList? Tags, bool? ShowWhenEmpty, string? ApiKey, bool? Restricted = null, IReadOnlyList? ReadPrincipals = null); diff --git a/src/Cocoar.Shelf/Models/UserDocument.cs b/src/Cocoar.Shelf/Models/UserDocument.cs index 462928b..9580075 100644 --- a/src/Cocoar.Shelf/Models/UserDocument.cs +++ b/src/Cocoar.Shelf/Models/UserDocument.cs @@ -5,7 +5,7 @@ namespace Cocoar.Shelf.Models; /// credentials, verification and 2FA live in modgud. This doc exists to hang app-specific user /// state off and to carry the cookie session (security stamp) via ASP.NET Identity. /// -public class UserDocument +public class UserDocument : IPrincipal { public Guid Id { get; set; } @@ -43,4 +43,9 @@ public class UserDocument /// When the snapshot was last refreshed (i.e. last login). Null for /// users provisioned before Access Control v2 who have not logged in since. public DateTimeOffset? ClaimsUpdatedAt { get; set; } + + PrincipalKind IPrincipal.PrincipalKind => PrincipalKind.User; + // Users are referenced by email so a grant can be staged before their first login. + string IPrincipal.PrincipalId => Email ?? ""; + string IPrincipal.PrincipalDisplayName => DisplayName ?? UserName; } diff --git a/src/Cocoar.Shelf/Program.cs b/src/Cocoar.Shelf/Program.cs index 289ef8b..aa01f20 100644 --- a/src/Cocoar.Shelf/Program.cs +++ b/src/Cocoar.Shelf/Program.cs @@ -223,6 +223,11 @@ }); } +// Serialize enums as strings in the HTTP API (e.g. PrincipalRef.Kind → "Group"/"User"), so the +// product access grants round-trip legibly between the SPA and the API. +builder.Services.ConfigureHttpJsonOptions(o => + o.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter())); + builder.Services.AddTransient(); builder.Services.AddAuthorization(options => diff --git a/src/Cocoar.Shelf/Services/Access/AccessResolver.cs b/src/Cocoar.Shelf/Services/Access/AccessResolver.cs index 2a448f7..6d3bd23 100644 --- a/src/Cocoar.Shelf/Services/Access/AccessResolver.cs +++ b/src/Cocoar.Shelf/Services/Access/AccessResolver.cs @@ -32,7 +32,7 @@ public async Task ResolveAsync(ClaimsPrincipal principal, Cancella var isAdmin = AdminCheck.IsAdmin(principal, options); var email = AdminCheck.Email(principal); var userId = CurrentUser.Id(principal); - var readable = new HashSet(StringComparer.OrdinalIgnoreCase); + var groupIds = new HashSet(); foreach (var group in await groups.GetAllAsync()) { @@ -42,12 +42,11 @@ public async Task ResolveAsync(ClaimsPrincipal principal, Cancella if (!isMember) continue; + groupIds.Add(group.Id); if (group.IsAdminGroup) isAdmin = true; - foreach (var product in group.ReadProducts) - readable.Add(product); } - return _cached = new AccessGrants(isAdmin, readable); + return _cached = new AccessGrants(isAdmin, groupIds, email); } } diff --git a/src/Cocoar.Shelf/Services/Access/IAccessResolver.cs b/src/Cocoar.Shelf/Services/Access/IAccessResolver.cs index c577310..3f047cc 100644 --- a/src/Cocoar.Shelf/Services/Access/IAccessResolver.cs +++ b/src/Cocoar.Shelf/Services/Access/IAccessResolver.cs @@ -1,23 +1,35 @@ using System.Security.Claims; +using Cocoar.Shelf.Models; namespace Cocoar.Shelf.Services.Access; /// -/// The effective grants of a principal, computed per request against the DB (Access Control v2): -/// whether they are a Shelf admin and which restricted products they may read. Cheap to consult -/// repeatedly — resolution is memoized per request. +/// The effective grant context of a principal, computed per request against the DB (Access Control +/// v2): whether they are a Shelf admin, and the identity (group memberships + email) needed to test a +/// product's read-principals. Cheap to consult repeatedly — resolution is memoized per request. /// -public sealed record AccessGrants(bool IsAdmin, IReadOnlySet ReadableProducts) +public sealed record AccessGrants(bool IsAdmin, IReadOnlySet GroupIds, string? Email) { - /// True if the principal may read (admins read everything). - public bool CanRead(string product) => IsAdmin || ReadableProducts.Contains(product); + /// True if the principal may read : admin, or listed among the + /// product's read-principals (a group they belong to, or their own email). + public bool CanRead(ProductConfig product) + { + if (IsAdmin) + return true; - public static readonly AccessGrants None = - new(false, new HashSet(StringComparer.OrdinalIgnoreCase)); + return product.ReadPrincipals.Any(p => p.Kind switch + { + PrincipalKind.Group => Guid.TryParse(p.Id, out var groupId) && GroupIds.Contains(groupId), + PrincipalKind.User => Email is not null && string.Equals(p.Id, Email, StringComparison.OrdinalIgnoreCase), + _ => false, + }); + } + + public static readonly AccessGrants None = new(false, new HashSet(), null); } /// -/// Resolves a principal's effective access from group grants, the token permission and the admin +/// Resolves a principal's grant context from group memberships, the token permission and the admin /// allowlist — the single per-request authority the enforcement points (docs middleware, product /// API, admin policy) consult. Revocation takes effect immediately (next request), not at next login. /// diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/AccessControlTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/AccessControlTests.cs index ab21c7f..36e4c39 100644 --- a/src/tests/Cocoar.Shelf.Tests/Integration/AccessControlTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/Integration/AccessControlTests.cs @@ -18,11 +18,19 @@ public class AccessControlTests public AccessControlTests(ShelfFixture fixture) => _fixture = fixture; - private void StoreProduct(string name, bool restricted, string visibility = "public") + private void StoreProduct(string name, bool restricted, params PrincipalRef[] readPrincipals) { var store = _fixture.Services.GetRequiredService(); using var session = store.LightweightSession(); - session.Store(new ProductConfig { Name = name, DisplayName = name, Source = "upload", Visibility = visibility, Restricted = restricted }); + session.Store(new ProductConfig + { + Name = name, + DisplayName = name, + Source = "upload", + Visibility = "public", + Restricted = restricted, + ReadPrincipals = readPrincipals.ToList(), + }); session.SaveChangesAsync().GetAwaiter().GetResult(); } @@ -78,15 +86,10 @@ public async Task RestrictedDocs_AuthenticatedNonMember_Returns404() [Fact] public async Task RestrictedDocs_GroupMemberByEmail_IsServed() { + var groupId = Guid.NewGuid(); + StoreGroup(new Group { Id = groupId, Name = "ac-member-grp", MemberEmails = ["ac-insider@shelf.test"] }); _fixture.CreateVersionDirectory("ac-member", "v1", "secret"); - StoreProduct("ac-member", restricted: true); - StoreGroup(new Group - { - Id = Guid.NewGuid(), - Name = "ac-member-grp", - MemberEmails = ["ac-insider@shelf.test"], - ReadProducts = ["ac-member"], - }); + StoreProduct("ac-member", restricted: true, PrincipalRef.ForGroup(groupId)); var client = await _fixture.CreateSignedInClientAsync("ac-insider@shelf.test"); var response = await client.GetAsync("/ac-member/v1/"); @@ -95,6 +98,19 @@ public async Task RestrictedDocs_GroupMemberByEmail_IsServed() Assert.Contains("secret", await response.Content.ReadAsStringAsync()); } + [Fact] + public async Task RestrictedDocs_DirectUserGrant_IsServed() + { + _fixture.CreateVersionDirectory("ac-direct-user", "v1", "secret"); + // Grant a single user directly (by email) — no group needed. + StoreProduct("ac-direct-user", restricted: true, PrincipalRef.ForUser("ac-direct@shelf.test")); + var client = await _fixture.CreateSignedInClientAsync("ac-direct@shelf.test"); + + var response = await client.GetAsync("/ac-direct-user/v1/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + [Fact] public async Task RestrictedDocs_Admin_IsServed() { @@ -184,18 +200,19 @@ public async Task AutoMembership_GrantsAccess_AfterMatchingUserLogsIn() { var admin = await _fixture.CreateSignedInClientAsync("ac-auto-admin@shelf.test", admin: true); - // Auto group: predicate matches by email domain, grants read on the restricted product. + // Auto group: predicate matches by email domain. var create = await admin.PostAsJsonAsync("/_api/groups", new { name = "ac-auto-grp", membershipMode = "Auto", membershipScript = "user.email.endsWith('@ac-auto.test')", - readProducts = new[] { "ac-auto-prod" }, }); Assert.Equal(HttpStatusCode.Created, create.StatusCode); + var group = await create.Content.ReadFromJsonAsync(); + // Grant the group read access on the restricted product (product-side principal grant). _fixture.CreateVersionDirectory("ac-auto-prod", "v1", "auto-secret"); - StoreProduct("ac-auto-prod", restricted: true); + StoreProduct("ac-auto-prod", restricted: true, PrincipalRef.ForGroup(group!.Id)); // Matching user signs in → login recompute materializes membership. var member = await _fixture.CreateSignedInClientAsync("someone@ac-auto.test"); @@ -238,4 +255,5 @@ public async Task TestScript_DryRun_ReportsMatch() private sealed record ProductDto(string Name, bool Restricted); private sealed record MeDto(bool IsAdmin); private sealed record TestScriptDto(bool Matched, string? Error); + private sealed record GroupIdDto(Guid Id); } From b9c97998ead90f0090efbd1e9bc47b09281c5777 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 19 Jul 2026 05:52:52 +0200 Subject: [PATCH 07/15] feat(ui): product access picker (groups + users) + Groups under Administration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Product modal Access section: when Restricted, a "Who may read" picker assigns principals — a searchable groups+users dropdown (from GET /principals) plus a free-email input to stage a user before their first login. Chips show kind icon. - Group modal loses the Products tab; group grid loses the Read-grants column (grants now live on the product). - Groups page moves under Administration (like Users): /admin/settings/groups sub-nav entry; top-level Groups sidebar link removed; header owned by the settings layout. - shelf-api getPrincipals; Product.readPrincipals + PrincipalRef/Principal models. Verified locally with chrome-devtools against the dev stack: Groups under Administration, picker lists groups + users, assigning a group and a staged user by email round-trips (product readPrincipals persists both kinds). Co-Authored-By: Claude Fable 5 --- .../src/core/api/shelf-api.ts | 5 +- .../src/core/models/shelf.models.ts | 23 +++- .../src/layouts/AdminLayout.vue | 7 -- src/Cocoar.Shelf.Client/src/router/index.ts | 29 +++-- .../src/views/admin/AdminSettingsView.vue | 6 ++ .../src/views/groups/GroupFormModal.vue | 39 +------ .../src/views/groups/GroupListView.vue | 13 +-- .../src/views/products/ProductFormModal.vue | 101 +++++++++++++++++- 8 files changed, 143 insertions(+), 80 deletions(-) diff --git a/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts b/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts index 607d1a5..1812639 100644 --- a/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts +++ b/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts @@ -1,7 +1,7 @@ import { http } from './http'; import type { Product, ProductVersions, CreateProductRequest, UpdateProductRequest, - GroupSummary, GroupDetail, GroupUpsertRequest, ScriptTestResult, + GroupSummary, GroupDetail, GroupUpsertRequest, ScriptTestResult, Principal, } from '../models/shelf.models'; export const shelfApi = { @@ -28,4 +28,7 @@ export const shelfApi = { recalculateGroups: () => http.post<{ ok: boolean }>('/groups/recalculate'), testGroupScript: (req: { script: string; userId?: string; email?: string }) => http.post('/groups/test-script', req), + + // Assignable principals (groups + users) for the product access picker. + getPrincipals: () => http.get('/principals'), }; diff --git a/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts index 06adc00..6a9ced4 100644 --- a/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts +++ b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts @@ -1,3 +1,19 @@ +/** A principal (group or user) that a product's read access can be granted to. */ +export type PrincipalKind = 'Group' | 'User'; + +export interface PrincipalRef { + /** Group id (GUID) or user email. */ + kind: PrincipalKind; + id: string; +} + +/** An assignable principal from GET /principals, for the product access picker. */ +export interface Principal { + kind: PrincipalKind; + id: string; + displayName: string; +} + export interface Product { name: string; displayName: string | null; @@ -6,6 +22,8 @@ export interface Product { visibility: string; /** Access control: hidden from users without a read grant, 404 on unauthorized access. */ restricted: boolean; + /** Principals (groups/users) granted read access when restricted. */ + readPrincipals: PrincipalRef[]; tags: string[]; showWhenEmpty: boolean; hasApiKey: boolean; @@ -28,6 +46,7 @@ export interface CreateProductRequest { tags?: string[]; showWhenEmpty?: boolean; restricted?: boolean; + readPrincipals?: PrincipalRef[]; /** Per-product upload key. Write-only: responses only carry hasApiKey. */ apiKey?: string; } @@ -40,6 +59,7 @@ export interface UpdateProductRequest { tags?: string[]; showWhenEmpty?: boolean; restricted?: boolean; + readPrincipals?: PrincipalRef[]; /** undefined = keep, '' = remove, value = replace. */ apiKey?: string; } @@ -61,7 +81,6 @@ export interface GroupSummary { membershipMode: MembershipMode; memberEmails: string[]; membershipScript: string | null; - readProducts: string[]; isAdminGroup: boolean; autoMemberCount: number; membershipLastError: string | null; @@ -80,7 +99,6 @@ export interface GroupDetail { membershipMode: MembershipMode; memberEmails: string[]; membershipScript: string | null; - readProducts: string[]; isAdminGroup: boolean; membershipLastError: string | null; /** Resolved materialized auto-members (read-only in the UI). */ @@ -93,7 +111,6 @@ export interface GroupUpsertRequest { membershipMode: MembershipMode; memberEmails?: string[]; membershipScript?: string; - readProducts?: string[]; isAdminGroup?: boolean; } diff --git a/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue b/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue index f3a7df3..0b03d92 100644 --- a/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue +++ b/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue @@ -122,13 +122,6 @@ function logout() { :active="route.path.startsWith('/admin/products')" @click="router.push('/admin/products')" /> - import('@/views/groups/GroupListView.vue'), - meta: { - admin: true, - routedFragments: [ - { - type: 'modal', - path: ':id', - component: () => import('@/views/groups/GroupFormModal.vue'), - overlayOptions: { size: { height: '80vh' } }, - }, - ], - }, - }, { path: 'profile', component: () => import('@/views/ProfileView.vue') }, { path: 'analytics', @@ -61,6 +46,20 @@ export const router = createRouter({ { path: '', redirect: '/admin/settings/general' }, { path: 'general', component: () => import('@/views/admin/GeneralSettingsView.vue') }, { path: 'users', component: () => import('@/views/admin/UserListView.vue') }, + { + path: 'groups', + component: () => import('@/views/groups/GroupListView.vue'), + meta: { + routedFragments: [ + { + type: 'modal', + path: ':id', + component: () => import('@/views/groups/GroupFormModal.vue'), + overlayOptions: { size: { height: '80vh' } }, + }, + ], + }, + }, { path: 'access-log', component: () => import('@/views/admin/AccessLogView.vue') }, { path: 'geoip', component: () => import('@/views/admin/GeoIpView.vue') }, ], diff --git a/src/Cocoar.Shelf.Client/src/views/admin/AdminSettingsView.vue b/src/Cocoar.Shelf.Client/src/views/admin/AdminSettingsView.vue index f1fab95..0ea2d81 100644 --- a/src/Cocoar.Shelf.Client/src/views/admin/AdminSettingsView.vue +++ b/src/Cocoar.Shelf.Client/src/views/admin/AdminSettingsView.vue @@ -38,6 +38,12 @@ function isActive(path: string): boolean { :class="{ 'admin-menu-item--active': isActive('/admin/settings/users') }" @clicked="router.push('/admin/settings/users')" /> + import { ref, computed, onMounted } from 'vue'; import { - CoarTextInput, CoarSelect, CoarCheckbox, CoarMultiSelect, CoarNote, CoarButton, - CoarFormField, CoarTabGroup, CoarTab, CoarTable, CoarTag, + CoarTextInput, CoarSelect, CoarCheckbox, CoarNote, CoarButton, + CoarFormField, CoarTabGroup, CoarTab, CoarTable, } from '@cocoar/vue-ui'; import { CoarScriptEditor } from '@cocoar/vue-script-editor'; import ModalLayout from '@/components/ModalLayout.vue'; import { useGroupsStore } from '@/stores/groups.store'; -import { useProductsStore } from '@/stores/products.store'; import { shelfApi } from '@/core/api/shelf-api'; import { ApiError } from '@/core/api/http'; import type { MembershipMode, GroupMember } from '@/core/models/shelf.models'; @@ -18,7 +17,6 @@ const props = defineProps<{ }>(); const groupsStore = useGroupsStore(); -const productsStore = useProductsStore(); const isCreate = computed(() => props.id === 'create'); const loading = ref(false); const saving = ref(false); @@ -36,7 +34,6 @@ const form = ref({ membershipMode: 'Manual' as MembershipMode, memberEmails: [] as string[], membershipScript: '', - readProducts: [] as string[], isAdminGroup: false, }); @@ -55,9 +52,6 @@ const isAuto = computed(() => form.value.membershipMode === 'Auto'); const scriptPreamble = 'declare const user: { email: string; permissions: string[]; claims: Record };'; -const productOptions = computed(() => - productsStore.items.map(p => ({ value: p.name, label: p.displayName || p.name }))); - const modalTitle = computed(() => (isCreate.value ? 'New Group' : form.value.name || props.id)); const footerButton = computed(() => ({ @@ -76,7 +70,6 @@ async function loadGroup() { membershipMode: group.membershipMode, memberEmails: [...group.memberEmails], membershipScript: group.membershipScript ?? '', - readProducts: [...group.readProducts], isAdminGroup: group.isAdminGroup, }; autoMembers.value = group.autoMembers; @@ -84,8 +77,6 @@ async function loadGroup() { } onMounted(async () => { - // Product read-grant options come from the products list. - if (productsStore.items.length === 0) productsStore.loadAll(); if (isCreate.value) return; loading.value = true; try { @@ -135,7 +126,6 @@ async function save() { membershipMode: form.value.membershipMode, memberEmails: form.value.memberEmails, membershipScript: isAuto.value ? form.value.membershipScript : undefined, - readProducts: form.value.readProducts, isAdminGroup: form.value.isAdminGroup, }; if (isCreate.value) { @@ -296,31 +286,6 @@ async function save() { - - Products - -
diff --git a/src/Cocoar.Shelf.Client/src/views/groups/GroupListView.vue b/src/Cocoar.Shelf.Client/src/views/groups/GroupListView.vue index 03bb11f..47118e8 100644 --- a/src/Cocoar.Shelf.Client/src/views/groups/GroupListView.vue +++ b/src/Cocoar.Shelf.Client/src/views/groups/GroupListView.vue @@ -3,11 +3,9 @@ import { computed, onMounted, ref } from 'vue'; import { CoarDataGrid, CoarGridBuilder } from '@cocoar/vue-data-grid'; import { CoarButton, CoarContextMenu, CoarMenuItem, CoarMenuDivider, useContextMenu, useDialog } from '@cocoar/vue-ui'; import { useFragmentNavigation, useRoutedModals } from '@cocoar/vue-fragment-parser'; -import { useUI } from '@/composables/useUI'; import { useGroupsStore } from '@/stores/groups.store'; import type { GroupSummary } from '@/core/models/shelf.models'; -const ui = useUI(); useRoutedModals(); const { navigateToModal } = useFragmentNavigation(); const groupsStore = useGroupsStore(); @@ -18,17 +16,10 @@ const viewportMenu = useContextMenu(); const dialog = useDialog(); const recalculating = ref(false); -ui.set((ctx) => { - ctx.header.title = 'Groups'; - ctx.header.subTitle = 'Grant restricted-product access and adminship'; - ctx.header.icon = 'users-round'; - ctx.content.container = false; -}); - const rowData = computed(() => groupsStore.items); const builder = CoarGridBuilder.create() - .persistColumnState('shelf-groups-v1') + .persistColumnState('shelf-groups-v2') .option('getRowId', (p: any) => p.data.id) .rowDataRef(rowData) .searchHighlight() @@ -53,8 +44,6 @@ const builder = CoarGridBuilder.create() (col: any) => col.field('membershipMode').header('Membership').width(130).option('minWidth', 110), (col: any) => col.field('members').header('Members').width(110).option('minWidth', 90) .option('valueGetter', (p: any) => (p.data?.memberEmails?.length ?? 0) + (p.data?.autoMemberCount ?? 0)), - (col: any) => col.field('readProducts').header('Read grants').width(120).option('minWidth', 100) - .option('valueGetter', (p: any) => p.data?.readProducts?.length ?? 0), (col: any) => col.field('isAdminGroup').header('Admin').width(90).option('minWidth', 80) .option('valueGetter', (p: any) => (p.data?.isAdminGroup ? 'yes' : '')), ]); diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue index 6a49c4d..bb5ae98 100644 --- a/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue @@ -2,13 +2,14 @@ import { ref, computed, onMounted } from 'vue'; import { CoarTextInput, CoarSelect, CoarCheckbox, CoarNote, CoarButton, CoarFormField, - CoarTabGroup, CoarTab, CoarTable, CoarTag, useDialog, + CoarTabGroup, CoarTab, CoarTable, CoarTag, CoarIcon, useDialog, } from '@cocoar/vue-ui'; import ModalLayout from '@/components/ModalLayout.vue'; import { useProductsStore } from '@/stores/products.store'; import { shelfApi } from '@/core/api/shelf-api'; import { ApiError } from '@/core/api/http'; import { generateApiKey, copyToClipboard } from '@/core/api-key-utils'; +import type { Principal, PrincipalRef } from '@/core/models/shelf.models'; const props = defineProps<{ id: string @@ -35,10 +36,50 @@ const form = ref({ source: 'upload', visibility: 'public', restricted: false, + readPrincipals: [] as PrincipalRef[], tags: [] as string[], showWhenEmpty: false, }); +// Access: assignable principals (groups + users) for the read-grant picker. +const principals = ref([]); +const principalEmail = ref(''); + +function principalKey(p: PrincipalRef | Principal): string { + return `${p.kind}:${p.id}`; +} + +const availablePrincipalOptions = computed(() => { + const chosen = new Set(form.value.readPrincipals.map(principalKey)); + return principals.value + .filter(p => !chosen.has(principalKey(p))) + .map(p => ({ value: principalKey(p), label: `${p.displayName} · ${p.kind}` })); +}); + +function principalLabel(p: PrincipalRef): string { + return principals.value.find(x => x.kind === p.kind && x.id === p.id)?.displayName ?? p.id; +} + +function addPrincipalByKey(key: string | null | undefined) { + if (!key) return; + const match = principals.value.find(p => principalKey(p) === key); + if (match && !form.value.readPrincipals.some(p => principalKey(p) === key)) { + form.value.readPrincipals.push({ kind: match.kind, id: match.id }); + } +} + +function addPrincipalEmail() { + const email = principalEmail.value.trim(); + if (email && !form.value.readPrincipals.some(p => p.kind === 'User' && p.id.toLowerCase() === email.toLowerCase())) { + form.value.readPrincipals.push({ kind: 'User', id: email }); + } + principalEmail.value = ''; +} + +function removePrincipal(p: PrincipalRef) { + form.value.readPrincipals = form.value.readPrincipals.filter(x => principalKey(x) !== principalKey(p)); +} + const tagInput = ref(''); const hasApiKey = ref(false); const newApiKey = ref(''); @@ -80,6 +121,7 @@ async function loadProduct() { source: product.source, visibility: product.visibility, restricted: product.restricted ?? false, + readPrincipals: [...(product.readPrincipals ?? [])], tags: [...(product.tags ?? [])], showWhenEmpty: product.showWhenEmpty ?? false, }; @@ -97,6 +139,9 @@ async function loadProduct() { } onMounted(async () => { + // Access picker options (groups + users). Best-effort — the modal still works without them. + shelfApi.getPrincipals().then(p => { principals.value = p; }).catch(() => { /* ignore */ }); + if (isCreate.value) return; loading.value = true; try { @@ -140,6 +185,7 @@ async function save() { source: form.value.source || undefined, visibility: form.value.visibility, restricted: form.value.restricted, + readPrincipals: form.value.readPrincipals, tags: form.value.tags, showWhenEmpty: form.value.showWhenEmpty, apiKey, @@ -256,14 +302,43 @@ async function onDeleteVersion(version: string) {

Restricted products are hidden from users without access and return 404 on - unauthorized requests. Grant read access to a group on the - Groups page. - Orthogonal to visibility (a product can be preview + restricted). + unauthorized requests. Orthogonal to visibility (a product can be preview + restricted).

+ +
+
Who may read
+
+ +
+
+ + Add +
+
+ + + {{ principalLabel(p) }} + + +
+

No one assigned yet — this product is admin-only.

+
@@ -478,6 +553,22 @@ async function onDeleteVersion(version: string) { } .access-desc a:hover { text-decoration: underline; } +.access-grants { + margin: 14px 0 0 26px; +} + +.grant-add-row { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.chip-icon { + font-size: 0.85rem; + opacity: 0.75; +} + .tag-input-row { display: flex; gap: 8px; From 5d1a81ffaf002d21607f3d71e16a7a9058a8013a Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 19 Jul 2026 10:26:25 +0200 Subject: [PATCH 08/15] feat(ui): move restricted access into its own Access tab in the product modal The Restricted toggle + "Who may read" principal picker move out of the General tab into a dedicated Access tab (General / Access / Tags & API / Versions), giving access control its own space instead of a nested block at the bottom of General. Behavior unchanged. Verified in the browser. Co-Authored-By: Claude Fable 5 --- .../src/views/products/ProductFormModal.vue | 106 ++++++++---------- 1 file changed, 46 insertions(+), 60 deletions(-) diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue index bb5ae98..82fe94d 100644 --- a/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductFormModal.vue @@ -298,49 +298,54 @@ async function onDeleteVersion(version: string) { v-model="form.showWhenEmpty" label="Show on landing page even without published versions" /> + + + -
- -

- Restricted products are hidden from users without access and return 404 on - unauthorized requests. Orthogonal to visibility (a product can be preview + restricted). -

- -
-
Who may read
-
- -
-
- - Add -
-
- - - {{ principalLabel(p) }} - - -
-

No one assigned yet — this product is admin-only.

+ + Access + @@ -538,25 +543,6 @@ async function onDeleteVersion(version: string) { .mt-2 { margin-top: 8px; } .mb-3 { margin-bottom: 12px; } -.access-block { - border-top: 1px solid var(--coar-border-neutral-tertiary); - padding-top: 12px; -} - -.access-desc { - margin: 8px 0 0 26px; -} - -.access-desc a { - color: var(--coar-text-accent-primary); - text-decoration: none; -} -.access-desc a:hover { text-decoration: underline; } - -.access-grants { - margin: 14px 0 0 26px; -} - .grant-add-row { display: flex; gap: 8px; From f2169cb3aed6263299699d93acfcc58c4304f188 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sun, 19 Jul 2026 10:41:19 +0200 Subject: [PATCH 09/15] feat(ui): unify landing + admin into one shell (shared header, dark mode everywhere) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public landing and the admin UI were two separate shells with different headers; the landing also had no dark mode. Now both share one AppLayout: - AppLayout (generalized from AdminLayout): the branded header is always present (logo, title via useUI, dark/light toggle); the right side shows the avatar menu when authenticated or a Sign-in button when anonymous. The admin sidebar renders only inside /admin (the landing has none). - Router: landing (/) and the admin routes are children of AppLayout, so the whole SPA is one consistent view. AdminLayout removed. - LandingView: drops its own custom header (moved to the shell), sets its title via useUI, and its CSS is migrated to vue-ui theme tokens so dark mode works on the docs index too. Note: this is the SPA (landing + admin). The VitePress-rendered product docs (/{product}/{version}/…) are separate static content with their own theme. Verified in the browser: shared header on landing + admin, sidebar only in admin, Sign-in button when anonymous, dark mode on the landing. Co-Authored-By: Claude Fable 5 --- .../{AdminLayout.vue => AppLayout.vue} | 46 +- src/Cocoar.Shelf.Client/src/router/index.ts | 21 +- .../src/views/LandingView.vue | 853 ++++++++---------- 3 files changed, 440 insertions(+), 480 deletions(-) rename src/Cocoar.Shelf.Client/src/layouts/{AdminLayout.vue => AppLayout.vue} (82%) diff --git a/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue b/src/Cocoar.Shelf.Client/src/layouts/AppLayout.vue similarity index 82% rename from src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue rename to src/Cocoar.Shelf.Client/src/layouts/AppLayout.vue index 0b03d92..0e25920 100644 --- a/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue +++ b/src/Cocoar.Shelf.Client/src/layouts/AppLayout.vue @@ -21,6 +21,10 @@ const route = useRoute(); const { state: ui } = provideUI(); const authStore = useAuthStore(); +// The sidebar is the admin navigation — shown only inside the admin area (which requires auth). +// The public landing (`/`) uses the same header/shell but no sidebar. +const isAdminArea = computed(() => route.path.startsWith('/admin')); + const collapsed = ref( localStorage.getItem('sidebar-collapsed') === 'true', ); @@ -59,11 +63,15 @@ function logout() {