Authentication & RBAC
- Add confidential OIDC client (Authentik) with /auth/oidc/login +
/auth/oidc/callback: discovery, code exchange, id_token verify (go-oidc),
groups claim → role (Archivum-admin → admin, else user). Sessions carry groups.
- Rework ACL into an allow/deny model (new `effect` column + migration).
db.EffectiveAccess resolves user + all groups over the path and its ancestors:
default deny, explicit deny always beats allow.
- Enforce ACL for ALL non-admin users (not just guest) across list/read/save/
delete/move/create/history/diff/images/upload. Admins bypass.
- Seed built-in Archivum-admin / Archivum-reader groups; login allow-list on
users & groups; public (guest) user access is ACL-configurable.
Admin API & UI
- New GraphQL ops: oidcConfig/updateOidcConfig, group CRUD, membership,
setUserRole/setUserLogin/setGroupLogin, userGroups, loginOptions.
- Rebuilt AdminView: SSO config, user/group management + membership, login
toggles, and an allow/deny access-control matrix per path.
- LoginView: "Sign in with Authentik" + public-user option; OIDC callback route.
Rendering/editor
- Fix bug where inline marks (bold/italic/code/strike/link) were dropped on
TipTap→AsciiDoc save. Add RENDERING_IMPROVEMENTS.md with proposals.
CI / build
- .gitea/workflows/build.yaml: build on the Pi5 runner, push
localhost:5000/archivum:{latest,<sha>}. Add .dockerignore; bump Go image to 1.25.
- Docs: ARCHITECTURE.md, README.md, docs/AUTHENTIK_SETUP.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
24 KiB
Archivum — Arkitektur och designdokumentation
Innehållsförteckning
1. Systemöversikt
Archivum är en självhostad wiki/dokumenthanterare. Alla dokument lagras som AsciiDoc-filer (.adoc) på disk och versionshanteras automatiskt med Git. Frontenden är en SPA (Single-Page Application) som kommunicerar med backenden uteslutande via GraphQL.
Webbläsare
│ HTTPS / HTTP
▼
┌──────────────────────────────┐
│ Go HTTP-server :4000 │
│ ┌──────────┐ ┌──────────┐ │
│ │ /graphql │ │ SPA / │ │
│ └────┬─────┘ └──────────┘ │
│ │ │
│ ┌────▼──────────────────┐ │
│ │ GraphQL dispatcher │ │
│ └─┬──────┬──────┬───────┘ │
│ Auth Storage Git │
│ (SQLite)(disk) (os/exec) │
└──────────────────────────────┘
Teknologistack
| Lager | Teknik |
|---|---|
| Frontend | Vue 3 (Composition API), Vite, TypeScript, Tailwind CSS |
| State | Pinia |
| Routing | Vue Router 4 |
| Editor | TipTap 2 (visuell, med custom image extension), CodeMirror 6 (AsciiDoc-källa) |
| AsciiDoc | Asciidoctor.js (rendering i webbläsaren) |
| PWA | vite-plugin-pwa (Service Worker, manifest) |
| Backend | Go 1.22, HTTP-standardbibliotek |
| Databas | SQLite via modernc.org/sqlite (CGO-fri, multi-arch) |
| Autentisering | OIDC/SSO (Authentik) + lokala konton (bcrypt) + LDAP, sessions-tokens i minnet |
| RBAC / ACL | Roll via OIDC-grupper; allow/deny-ACL per sökväg för användare & grupper |
| Versionshantering | Git via os/exec |
| Containerisering | Docker (multi-stage, multi-arch: amd64 + arm64) |
2. Frontend
Paket och beroenden
frontend/
├── src/
│ ├── App.vue # Rotemponent — kontrollerar setup/login/app-läge
│ ├── main.ts # Appstart, monterar Vue + Pinia + Router
│ ├── lib/
│ │ ├── gql.ts # Minimal GraphQL-klient (fetch-baserad)
│ │ └── crypto.ts # Lösenords-hashing (SHA-256 via Web Crypto API)
│ ├── stores/
│ │ ├── app.ts # Global state: token, requiresSetup, login/logout
│ │ └── theme.ts # Mörkt/ljust läge, persistas i localStorage
│ ├── router/index.ts # SPA-routes
│ ├── views/ # Sidkomponenter
│ ├── components/ # Återanvändningsbara UI-komponenter
│ │ ├── editor/ # Visuell och källkodseditor
│ │ ├── layout/ # AppLayout, Sidebar, ThemeToggle
│ │ └── wizard/ # Installationsguide (SetupWizard)
│ ├── bridge/
│ │ └── asciidoc-bridge.ts # AsciiDoc ↔ TipTap JSON-konvertering
│ └── assets/main.css # Tailwind-bas + CSS-variabler
Routing
| Sökväg | Komponent | Åtkomstkrav |
|---|---|---|
/ |
HomeView | Inloggad |
/doc/:slug |
DocumentView | Inloggad |
/admin |
AdminView | Inloggad, admin |
/setup |
SetupWizard | Ej konfigurerat |
Routeskydd hanteras inte med router guards utan direkt i App.vue — se avsnitt 4.1.
GraphQL-klienten (lib/gql.ts)
Alla API-anrop görs med en enkel fetch-wrapper. Klienten:
- Läser JWT-token från
localStorageoch skickar den somAuthorization: Bearer <token> - Kastar ett
Errorom svaret innehåller GraphQL-errors - Hanterar automatisk utloggning vid
UNAUTHORIZED-fel
Lösenordshantering (lib/crypto.ts)
Lösenordet hashas på klienten innan det skickas till servern med:
SHA-256(lowercase(username) + ":" + password)
Web Crypto API (crypto.subtle.digest) används — ingen extern beroende. Username fungerar som domän-separator (salt) så att samma lösenord ger olika digest för olika konton. Backenden tar emot denna digest och lagrar bcrypt(digest).
PWA
Konfigureras i vite.config.ts via vite-plugin-pwa:
- Service Worker med
skipWaiting+clientsClaim(ny SW aktiveras omedelbart) - Cachar statiska assets (JS, CSS, fonts)
- HTML och
/graphqlgår alltid till nätverket (NetworkFirst) navigateFallback: null— förhindrar att SW returnerar cachat HTML för routes
3. Backend
Paket
backend/
├── cmd/server/main.go # Startpunkt — laddar config, startar HTTP-server
├── internal/
│ ├── config/config.go # Config-struktur, Load/Save, ErrRequireSetup
│ ├── auth/auth.go # Session-hantering, bcrypt, LDAP-bind
│ ├── db/db.go # SQLite-wrapper — users-tabell
│ ├── storage/storage.go # Filhantering — Read/Write/List .adoc-filer
│ ├── git/git.go # Git-wrapper via os/exec — Commit/Log/Diff
│ └── graph/
│ ├── server.go # HTTP-handler + handgjord GraphQL-dispatcher
│ ├── resolver.go # Resolver-stub (plats för gqlgen-genererad kod)
│ └── schema.graphql # GraphQL-schema
GraphQL-dispatcher
Backenden använder inte gqlgen-genererad kod i produktion. server.go implementerar en handgjord dispatcher som parsar query-strängen med strings.Contains och router till respektive handler-funktion. Detta ger enkel deployment utan kodgenerering men kräver manuell underhåll av schema och handlers.
Autentisering och sessioner
Login-flöde (lokalt konto):
1. Frontend: kontrollerar authType (query userAuthType) → "local"
2. Frontend: SHA-256(username:password) → skickas som "password"
3. Backend: bcrypt.CompareHashAndPassword(stored_hash, received_digest)
4. Vid match: slumpmässig hex-token genereras och sparas i minnet
5. Token returneras till klient, lagras i localStorage
Login-flöde (LDAP-konto):
1. Frontend: kontrollerar authType → "ldap"
2. Frontend: skickar klartextlösenord i fältet "ldapPassword" (EJ hashat)
3. Backend: hämtar användarens DN via LDAP-sökning (baseDN + filter)
4. Backend: binder till LDAP med DN + klartextlösenord (ldapBind)
5. Vid lyckad bind: session skapas med roll "user"
Login-flöde (gäst):
1. Frontend: klickar "Continue as Guest" → skickar username="guest", password=""
2. Backend: identifierar gäst, skapar session utan lösenordskontroll, roll "guest"
3. Gäst ser bara dokument/mappar som admin explicit gett tillåtelse via ACL
Session-storage: in-memory map[token]*Session (försvinner vid omstart)
Session-livstid: 24 timmar (konfigurerat i auth.go)
**Rollbaserad UI-synlighet:**
Frontendens Pinia-store (`app.ts`) lagrar den inloggade användarens roll (`admin`, `user`, `guest`) via query `currentUserRole` som körs direkt efter inloggning. Admin-panelslänken i sidofältet visas bara om `role === 'admin'`.
Viktigt: Sessions lagras enbart i minnet. Vid server-omstart måste alla klienter logga in igen. LDAP-konfigurationsändringar (via admin-panelen) nollställer inte sessioner — befintliga sessioner är fortfarande giltiga.
Gästanvändare
En inbyggd guest-användare skapas automatiskt vid installation och vid serverstart (migrations-safe). Gästen:
- Kräver inget lösenord
- Har roll
"guest"(skild från"user"och"admin") - Kan som standard inte se något
- Admin ger tillgång per dokument eller mapp via ACL-panelen
Autentisering via OIDC (Authentik)
Archivum är en confidential OIDC-klient mot Authentik. Flödet är rent browser-redirect och ligger utanför GraphQL (två HTTP-endpoints):
Login-sida → GET /auth/oidc/login
→ 302 till Authentik (authorize, scope: openid profile email groups, state)
→ användaren autentiseras (Authentik → OpenLDAP)
→ 302 tillbaka GET /auth/oidc/callback?code&state
├─ validerar state (CSRF), byter code→token (backend↔Authentik, TLS)
├─ verifierar id_token (JWKS via go-oidc), läser preferred_username + groups
├─ mappar grupp → roll: Archivum-admin → admin, annars user
├─ speglar användare + grupper till SQLite (för admin-UI + ACL-mål)
├─ login-grind: användare/grupp måste vara tillåten
└─ 302 till /oidc/callback#token=… (SPA:n läser token ur fragmentet)
Konfiguration ligger i config.oidc (issuer, client_id/secret, grupp-mappning)
och sätts i admin-panelen. Discovery körs i bakgrunden vid start/ändring så att
en otillgänglig IdP inte blockerar servern. Se docs/AUTHENTIK_SETUP.md.
Åtkomstkontroll (allow/deny-ACL)
ACL-poster lagras i SQLite-tabellen acl. Varje post kopplar en sökväg (slug
eller mappnamn) till ett subjekt (användare eller grupp), en effekt
(allow/deny) och sju rättighetsflaggor:
| Flagga | Innebär |
|---|---|
| canSearch | Syns i sökning/listning |
| canView | Syns i mapplistning |
| canRead | Kan öppna och läsa innehållet |
| canEdit | Kan redigera och spara |
| canCreate | Kan skapa nya dokument i mappen |
| canDelete | Kan radera |
| canMove | Kan flytta/byta namn |
Effektiv rättighet (db.EffectiveAccess) beräknas för (användare, sökväg,
handling) genom att kombinera alla poster som gäller subjektet — användaren
själv plus alla dess grupper (från OIDC-claimet eller lokalt medlemskap) —
över sökvägen och alla dess överliggande mappar:
- Standard = deny.
- En
allowpå handlingen ger tillgång. - En
denypå handlingen vinner alltid över allow.
Alltså: tillgång ges bara om någon post tillåter och ingen post nekar. Sökvägar
är hierarkiska (en allow på mappen docs gäller allt under docs/).
Enforcement sker för alla icke-admins (inloggade användare, Archivum-reader
och den publika/gäst-användaren) i alla operationer — listning, läsning, spara,
radera, flytta, skapa, historik/diff och bilduppladdning. Admin kringgår ACL.
Känd begränsning:
/media/<bild>serveras utan ACL eftersom bilder laddas via<img>utan Authorization-header. Skydda känsliga bilder på annat sätt (t.ex. signerade URL:er) om det behövs.
Setup-detektion — needsSetup
Backenden räknar ett system som oklonfigurerat (REQUIRE_SETUP) om något av följande är sant:
config.jsonsaknas eller är ofullständig (cfg == nil)- Databasen kunde inte öppnas (
database == nil) - Databasen är tom — ingen adminanvändare har skapats (
!database.HasUsers())
db.HasUsers() gör en enkel SELECT COUNT(*) FROM users och returnerar true om minst ett konto finns. Detta innebär att en nyskapad, tom SQLite-fil (som Go skapar vid db.New()) fortfarande triggar setup-läge.
DB-sökväg — resolveDBPath()
Setup-handleren bestämmer DB-sökvägen dynamiskt för att fungera i både Docker och dev-miljö:
resolveDBPath(configPath):
├─ Kan /data/db skapas/skrivas? → /data/db/archivum.db (Docker-volym)
└─ Annars → <config-katalog>/archivum.db (dev-miljö)
Konfiguration
config.json hanteras av internal/config:
storage_path— rotkatalog för .adoc-filer och Git-repodb_path— sökväg till SQLite-filjwt_secret— används för session-token generation (ej JWT i klassisk mening)listen_addr— TCP-adress att lyssna på, t.ex.:4000public_url— extern bas-URL (t.ex.https://archivum.brasse-pc.eu), används för att härleda OIDC-redirect-URIoidc— valfri OIDC/SSO-konfiguration (Authentik):enabled,issuer,client_id,client_secret,redirect_urlgroups_claim(defaultgroups),username_claim(defaultpreferred_username)admin_group(defaultArchivum-admin),reader_group(defaultArchivum-reader)
ldap— valfri LDAP-konfiguration (direkt bind, äldre alternativ):url— LDAP-server URL, t.ex.ldap://openldap:389base_dn— rot-DN att söka ifrån, t.ex.dc=example,dc=comadmin_user— tjänstekonto DN för uppslag (valfritt)admin_pass— tjänstekontoets lösenord (valfritt)
Om filen saknas returnerar Load() ErrRequireSetup — backenden startar i setup-läge.
4. Exekveringsflöden
4.1 Appstart och setup-detektion
Browser laddar /
│
▼
App.vue onMounted()
│
├─ theme.init() (laddar tema från localStorage)
│
├─ app.checkStatus() → POST /graphql { systemStatus }
│ │ Backend: needsSetup = cfg==nil || db==nil || !db.HasUsers()
│ │
│ ├─ svar: "REQUIRE_SETUP"
│ │ → app.requiresSetup = true
│ │ → visas: <SetupWizard /> (renderas direkt, ej via router)
│ │
│ └─ svar: "OK"
│ → app.requiresSetup = false
│ ├─ app.token finns: → visas: <AppLayout />
│ └─ app.token saknas: → visas: <LoginView />
│
└─ ready = true (spinner försvinner)
SetupWizard, LoginView och AppLayout renderas direkt i App.vue (inte via <router-view>) för att undvika race conditions mellan router.replace() och Vue-renderingen.
4.2 Installationsguiden (SetupWizard)
Steg 1: Ange storage-sökväg, admin-användare, lösenord
Steg 2: Valfri LDAP-konfiguration
Steg 3: Bekräftelse
Vid submit:
1. SHA-256(adminUser:adminPass) → hashedPass
2. POST /graphql mutation Setup { setup(input: {..., adminPass: hashedPass}) }
├─ Backend: resolveDBPath() → bestämmer db_path (Docker vs dev)
├─ Backend: skapar SQLite-DB och admin-användare med bcrypt(hashedPass) ← FÖRST
├─ Backend: sparar config.json ← SEDAN
└─ Backend: initierar storage-katalog och Git-repo
3. app.requiresSetup = false
4. app.login(adminUser, adminPass) → SHA-256 igen → POST /graphql mutation Login
5. Redirect till /
Ordningen är viktig: om DB-skapandet eller user-insert misslyckas sparas ingen config.json. Nästa sidladdning ger återigen REQUIRE_SETUP och guiden visas på nytt. Tidigare sparades config först, vilket kunde lämna ett halvfärdigt tillstånd där backenden startade med config men utan adminanvändare.
4.3 Inloggningsflöde
Användaren fyller i användarnamn + lösenord (eller klickar "Continue as Guest")
│
▼
app.login(username, password)
│
├─ query userAuthType(username) → "local" | "ldap" | "guest"
│
├─ authType == "local":
│ SHA-256(lowercase(username):password) → hashed
│ → POST /graphql mutation Login(u, p: hashed)
│
├─ authType == "ldap":
│ → POST /graphql mutation Login(u, p: "", ldapPassword: plaintext)
│
└─ authType == "guest":
→ POST /graphql mutation Login(u: "guest", p: "")
│
▼
handleLogin (server.go)
│
├─ "guest": returnerar token utan lösenordskontroll
│
├─ "ldap" (is_ldap=1 i DB): ldapBind(username, ldapPassword)
│ → OK: session med roll "user"
│
└─ "local" (is_ldap=0): bcrypt.Compare(stored, hashed)
→ OK: session med roll baserad på DB-värde
Gäst-session:
- Roll "guest" — se ACL-enforcement i handleDocuments / handleFolders
- Kan bara se dokument/mappar som admin explicit beviljat via ACL
4.4 Spara dokument
Användaren redigerar och klickar Spara
│
▼
POST /graphql mutation SaveDocument
{ slug, content (AsciiDoc), commitMessage, author }
│
▼
handleSaveDocument (server.go)
│
├─ Kontrollerar session (Bearer token → Manager.Validate())
├─ storage.Write(slug, content) → skriver slug.adoc till disk
└─ git.Commit(slug.adoc, message, author, email)
│
└─ os/exec: git add → git commit
4.5 Versionshistorik och diff
GET /graphql { history(slug) }
│
└─ git.Log(slug.adoc) → os/exec: git log --format=...
→ []LogEntry { Hash, Author, Date, Subject }
GET /graphql { diff(slug, fromHash, toHash) }
│
└─ git.Diff(slug.adoc, from, to) → os/exec: git diff
→ unified diff-sträng
5. Kodstruktur
Fullständig katalogstruktur
Archivum/
├── backend/
│ ├── cmd/server/main.go # Entrypoint
│ ├── config.json # Lokal dev-konfiguration (gitignoreras ej — bör .gitignore:as i produktion)
│ ├── go.mod / go.sum
│ └── internal/
│ ├── auth/auth.go # Session, bcrypt, LDAP
│ ├── config/config.go # Config-struktur och fil-I/O
│ ├── db/db.go # SQLite users-tabell
│ ├── git/git.go # Git-wrapper
│ ├── storage/storage.go # .adoc fil-I/O
│ └── graph/
│ ├── schema.graphql # GraphQL-schema (source of truth)
│ ├── resolver.go # Resolver-stub
│ └── server.go # HTTP-server + dispatcher + alla handlers
│
├── frontend/
│ ├── index.html
│ ├── vite.config.ts # Vite + PWA + dev-proxy konfiguration
│ ├── tailwind.config.js
│ ├── package.json
│ └── src/
│ ├── App.vue # Rotkomponent med setup/login/app-routing
│ ├── main.ts
│ ├── lib/
│ │ ├── gql.ts # GraphQL fetch-klient
│ │ └── crypto.ts # SHA-256 lösenordshashing
│ ├── stores/
│ │ ├── app.ts # Global app-state (Pinia)
│ │ └── theme.ts # Temahantering (Pinia)
│ ├── router/index.ts
│ ├── views/
│ │ ├── HomeView.vue # Dokumentlista
│ │ ├── DocumentView.vue # Visa/redigera dokument
│ │ ├── AdminView.vue # Administrationspanel
│ │ └── LoginView.vue # Inloggningsformulär
│ ├── components/
│ │ ├── editor/
│ │ │ ├── VisualEditor.vue # TipTap WYSIWYG-editor
│ │ │ └── SourceEditor.vue # CodeMirror AsciiDoc-källeditor
│ │ ├── layout/
│ │ │ ├── AppLayout.vue # Huvudlayout med sidebar
│ │ │ ├── Sidebar.vue # Dokumentnavigering
│ │ │ └── ThemeToggle.vue # Ljust/mörkt läge-knapp
│ │ └── wizard/
│ │ └── SetupWizard.vue # Installationsguide (3 steg)
│ ├── bridge/
│ │ └── asciidoc-bridge.ts # AsciiDoc ↔ TipTap JSON
│ └── assets/main.css
│
├── docker/
│ ├── Dockerfile # Multi-stage build (frontend → backend → runtime)
│ ├── entrypoint.sh # PUID/PGID-hantering, katalogskapande
│ ├── docker-compose.yml # Dev/standard deploy
│ └── docker-compose.prod.yml # Produktionsinställningar
│
└── ARCHITECTURE.md # Detta dokument
Nyckelgränssnitt och datakontrakt
GraphQL-schema (urval):
type Query {
systemStatus: SystemStatus!
userAuthType(username: String!): String! # "local" | "ldap" | "guest"
currentUserRole: String! # Returnerar rollen för inloggad session ("admin" | "user" | "guest")
documents(prefix: String): [DocumentMeta!]!
document(slug: String!): Document
folders: [String!]!
history(slug: String!): [CommitEntry!]!
diff(slug: String!, fromHash: String!, toHash: String!): String!
acl(path: String!): [ACLEntry!]!
aclSubjects: ACLSubjects! # Användare + grupper för ACL-picker (inkl. guest)
ldapBrowse(url: String, baseDN: String, adminUser: String, adminPass: String): LDAPTree!
}
type Mutation {
login(username: String!, password: String!, ldapPassword: String): String!
logout: Boolean!
setup(input: SetupInput!): Boolean!
saveDocument(input: SaveDocumentInput!): Document!
deleteDocument(slug: String!): Boolean!
updateLdapConfig(input: LDAPInput): Boolean!
importLdapSubject(type: String!, name: String!): Boolean!
setAcl(input: ACLInput!): Boolean!
removeAcl(id: Int!): Boolean!
createFolder(path: String!): Boolean!
moveDocument(oldSlug: String!, newSlug: String!): Boolean!
}
input LDAPInput {
url: String!
baseDN: String
adminUser: String!
adminPass: String # Utelämnas → befintligt lösenord behålls
}
6. Containerisering och driftsättning
Dockerfile (multi-stage)
Stage 1 (frontend-builder) — node:20-alpine
npm install && npm run build → /app/frontend/dist
Stage 2 (backend-builder) — golang:1.22-alpine
CGO_ENABLED=0 go build → /archivum (statisk binär)
Stage 3 (runtime) — alpine:3.20
Installerar: su-exec, git
Kopierar: dist/ → /srv/archivum/ui
archivum → /usr/local/bin/archivum
entrypoint.sh
Volumes: /config, /data
Port: 4000
Entrypoint: entrypoint.sh
Volymer och kataloger
| Volym | Innehåll | Env-variabel |
|---|---|---|
/config |
config.json |
DOCKER_PATH |
/data |
wiki/ (AsciiDoc + Git-repo), db/archivum.db |
— |
Miljövariabler
| Variabel | Standard | Beskrivning |
|---|---|---|
DOCKER_PATH |
— | Host-sökväg som monteras som /config |
UI_DIR |
/srv/archivum/ui |
Sökväg till kompilerad frontend |
PUID |
1000 |
Filsystemsägare (UID) |
PGID |
1000 |
Filsystemsägare (GID) |
TZ |
Europe/Stockholm |
Tidszon |
HOST_PORT |
8080 |
Extern port i docker-compose |
Multi-arch build
Bilden stöder linux/amd64 (server/desktop) och linux/arm64 (Raspberry Pi 5):
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t registry:5000/archivum:latest \
-f docker/Dockerfile --push .
entrypoint.sh — PUID/PGID-hantering
Containern startar som root. entrypoint.sh:
- Skapar
/config,/data/wiki,/data/dbom de saknas - Ändrar ägare på volymerna till
PUID:PGID - Kör applikationen som
PUID:PGIDviasu-exec
docker-compose.yml (minimal .env)
DOCKER_PATH=/opt/archivum # Host-sökväg — config/ och data/ skapas här
HOST_PORT=8080
PUID=1000
PGID=1000
TZ=Europe/Stockholm
Uppgraderingsförfarande
- Bygg/hämta ny image
docker compose downdocker compose up -d
Inga databas-migreringar behövs i de flesta uppgraderingar; SQLite-schemat är bakåtkompatibelt (CREATE TABLE IF NOT EXISTS).