From 918003354342a14f606489af4a3c958434b52c59 Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Tue, 7 Jul 2026 20:28:16 +0200 Subject: [PATCH] feat(acl): tree-based access editor + permission-aware UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin — Access Control is now a folder/file tree. Pick a user/group, click a node to set an allow and/or deny rule there; each node shows the subject's *effective* rights (letters S V R E C D M) and allow/deny "set here" badges, computed server-side incl. group membership, inherited folder rules and deny-wins. New queries: myAccess(paths) and subjectAccess(subjectType,subjectId,paths). Main UI — actions are hidden when the current (non-admin) user lacks the permission: create folder/document (Sidebar), drag-to-move, and Save/Delete in the document view. Adds a permission-gated Delete button to the document toolbar. Admins bypass and see everything. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/internal/db/db.go | 14 + backend/internal/graph/schema.graphql | 18 ++ backend/internal/graph/server.go | 86 ++++++ frontend/src/components/layout/Sidebar.vue | 29 ++- frontend/src/lib/access.ts | 35 +++ frontend/src/views/AdminView.vue | 289 +++++++++++++++------ frontend/src/views/DocumentView.vue | 49 +++- 7 files changed, 436 insertions(+), 84 deletions(-) create mode 100644 frontend/src/lib/access.ts diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index b6e55cf..38f8ede 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -691,6 +691,20 @@ func (d *DB) EffectiveAccess(username string, groupNames []string, path string) } } +// UsernameByID returns the username for a user id, or "" if not found. +func (d *DB) UsernameByID(id int64) string { + var n string + d.sql.QueryRow(`SELECT username FROM users WHERE id=?`, id).Scan(&n) + return n +} + +// GroupNameByID returns the group name for a group id, or "" if not found. +func (d *DB) GroupNameByID(id int64) string { + var n string + d.sql.QueryRow(`SELECT name FROM groups WHERE id=?`, id).Scan(&n) + return n +} + func (d *DB) userID(username string) (int64, bool) { var id int64 err := d.sql.QueryRow(`SELECT id FROM users WHERE username=?`, username).Scan(&id) diff --git a/backend/internal/graph/schema.graphql b/backend/internal/graph/schema.graphql index e861a44..17fe3d1 100644 --- a/backend/internal/graph/schema.graphql +++ b/backend/internal/graph/schema.graphql @@ -61,6 +61,24 @@ type Query { # Names of the groups a user belongs to (admin only). userGroups(username: String!): [String!]! + + # Effective permissions the current user has on each path (for UI gating). + myAccess(paths: [String!]!): [PathAccess!]! + + # Effective permissions a subject has on each path (admin only, for the + # access-control tree). For a user this includes their group memberships. + subjectAccess(subjectType: String!, subjectId: Int!, paths: [String!]!): [PathAccess!]! +} + +type PathAccess { + path: String! + canSearch: Boolean! + canView: Boolean! + canRead: Boolean! + canEdit: Boolean! + canCreate: Boolean! + canDelete: Boolean! + canMove: Boolean! } type Mutation { diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index ffadd90..56e078c 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -225,6 +225,13 @@ func (s *Server) dispatchAuthenticated( q := req.Query switch { + // ── Effective-access queries (matched before generic acl/subject cases) ──── + case strings.Contains(q, "myAccess"): + s.handleMyAccess(w, req, sess) + + case strings.Contains(q, "subjectAccess"): + s.handleSubjectAccess(w, req, sess) + // ── Admin: OIDC / groups / membership / login gate (matched first) ───────── case strings.Contains(q, "updateOidcConfig"): s.handleUpdateOidcConfig(w, req, sess) @@ -2542,3 +2549,82 @@ func boolVal(m map[string]interface{}, key string) bool { b, _ := m[key].(bool) return b } + +func strSlice(m map[string]interface{}, key string) []string { + raw, ok := m[key].([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok { + out = append(out, s) + } + } + return out +} + +func permMap(path string, p db.Perms) map[string]interface{} { + return map[string]interface{}{ + "path": path, + "canSearch": p.Search, + "canView": p.View, + "canRead": p.Read, + "canEdit": p.Edit, + "canCreate": p.Create, + "canDelete": p.Delete, + "canMove": p.Move, + } +} + +// handleMyAccess returns the current session's effective permissions for each +// requested path. Used by the UI to hide actions the user cannot perform. +func (s *Server) handleMyAccess(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if sess == nil { + writeGQLError(w, "UNAUTHORIZED") + return + } + paths := strSlice(req.Variables, "paths") + out := make([]map[string]interface{}, 0, len(paths)) + for _, p := range paths { + out = append(out, permMap(p, s.access(sess, p))) + } + writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"myAccess": out}}) +} + +// handleSubjectAccess returns the effective permissions a given subject +// (user or group) has for each requested path — for the admin access tree. +// For a user this includes their group memberships; for a group it is the +// group's own rules. Both walk ancestor folders with deny-wins. +func (s *Server) handleSubjectAccess(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if !s.requireAdmin(w, sess) { + return + } + database := s.db() + if database == nil { + writeGQLError(w, "database not initialised") + return + } + subjectType := strVal(req.Variables, "subjectType") + idF, ok := req.Variables["subjectId"].(float64) + if !ok { + writeGQLError(w, "missing subjectId") + return + } + paths := strSlice(req.Variables, "paths") + + var username string + var groups []string + if subjectType == "group" { + groups = []string{database.GroupNameByID(int64(idF))} + } else { + username = database.UsernameByID(int64(idF)) + groups, _ = database.GetUserGroupNames(username) + } + + out := make([]map[string]interface{}, 0, len(paths)) + for _, p := range paths { + out = append(out, permMap(p, database.EffectiveAccess(username, groups, p))) + } + writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"subjectAccess": out}}) +} diff --git a/frontend/src/components/layout/Sidebar.vue b/frontend/src/components/layout/Sidebar.vue index 86625df..3f4b756 100644 --- a/frontend/src/components/layout/Sidebar.vue +++ b/frontend/src/components/layout/Sidebar.vue @@ -3,11 +3,33 @@ import { ref, reactive, onMounted, computed, watch } from 'vue' import { useRouter, useRoute } from 'vue-router' import { gql, createFolder, moveDocument } from '@/lib/gql' import { useAppStore } from '@/stores/app' +import { fetchMyAccess, type PathAccess } from '@/lib/access' import CreateItemDialog from './CreateItemDialog.vue' const emit = defineEmits<{ close: [] }>() const app = useAppStore() +// ── Permission gating ────────────────────────────────────────────────────── +const isAdmin = computed(() => app.role === 'admin') +const perms = ref>({}) + +function canCreate(path: string) { + return isAdmin.value || !!perms.value[path]?.canCreate +} +function canMove(slug: string) { + return isAdmin.value || !!perms.value[slug]?.canMove +} + +async function refreshPerms() { + if (isAdmin.value) return + try { + const paths = ['', ...allFolders.value, ...allDocs.value.map(d => d.slug)] + perms.value = await fetchMyAccess(paths) + } catch { + perms.value = {} + } +} + interface DocMeta { slug: string; title: string } interface TreeFolder { @@ -101,6 +123,7 @@ async function handleDialogConfirm(name: string) { const fData = await gql<{ folders?: string[] }>(`{ folders }`) allFolders.value = fData.folders ?? [] if (parentPath) openFolders[parentPath] = true + await refreshPerms() } catch (err) { console.error('Failed to create folder:', err) } @@ -175,6 +198,7 @@ onMounted(async () => { openFolders[parts.slice(0, i).join('/')] = true } } + await refreshPerms() } catch { allDocs.value = [] allFolders.value = [] @@ -309,6 +333,7 @@ function isActive(slug: string) { {{ item.name }} - - - +
+ +
+
+ 📂 Tree + {{ selectedSubject.name }}
+
    +
  • + + + {{ row.type === 'folder' ? (row.path === '' ? '🏠' : '📁') : '📄' }} + {{ row.label }} + allow + deny + {{ effLetters(row.path) }} +
  • +
-
-

Add rule

-
-
- - -
-
-
-

Select a user or group to manage their access.

diff --git a/frontend/src/views/DocumentView.vue b/frontend/src/views/DocumentView.vue index 159dec5..86a315c 100644 --- a/frontend/src/views/DocumentView.vue +++ b/frontend/src/views/DocumentView.vue @@ -2,6 +2,8 @@ import { ref, onMounted, watch, computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { gql } from '@/lib/gql' +import { fetchMyAccess, type PathAccess } from '@/lib/access' +import { useAppStore } from '@/stores/app' import VisualEditor from '@/components/editor/VisualEditor.vue' import SourceEditor from '@/components/editor/SourceEditor.vue' import HistoryPanel from '@/components/history/HistoryPanel.vue' @@ -10,11 +12,33 @@ import type { CommitEntry } from '@/components/history/HistoryPanel.vue' const route = useRoute() const router = useRouter() +const app = useAppStore() const slug = computed(() => { const s = route.params.slug return Array.isArray(s) ? s.join('/') : (s as string) }) +// ── Permission gating ────────────────────────────────────────────────────── +const isAdmin = computed(() => app.role === 'admin') +const docAccess = ref(null) +// New docs are reached via a create-gated action, so editing is allowed there. +const canEditDoc = computed(() => isAdmin.value || slug.value === 'new' || !!docAccess.value?.canEdit) +const canDeleteDoc = computed(() => slug.value !== 'new' && (isAdmin.value || !!docAccess.value?.canDelete)) +const deleting = ref(false) + +async function deleteDoc() { + if (!confirm(`Delete "${slug.value}"? This cannot be undone.`)) return + deleting.value = true + try { + await gql(`mutation Del($slug: String!) { deleteDocument(slug: $slug) }`, { slug: slug.value }) + router.push('/') + } catch (err: any) { + alert(err?.message || 'Failed to delete') + } finally { + deleting.value = false + } +} + // ── Normal editing state ────────────────────────────────────────────────────── const docPath = ref('') @@ -30,6 +54,7 @@ async function loadDocument(s: string) { exitVersionView() exitDiffView() + docAccess.value = null if (s === 'new') { content.value = '' docPath.value = '' @@ -47,6 +72,12 @@ async function loadDocument(s: string) { { s }, ) content.value = data.document?.content ?? '' + if (!isAdmin.value) { + try { + const acc = await fetchMyAccess([s]) + docAccess.value = acc[s] ?? null + } catch { /* leave null → buttons hidden */ } + } } finally { loading.value = false } @@ -230,8 +261,8 @@ function formatDate(dateStr: string) { placeholder="New filename (e.g. folder/doc)" /> - -