feat: Authentik OIDC SSO, allow/deny RBAC, admin console & Gitea CI
All checks were successful
build-and-push / build (push) Successful in 15m15s

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>
This commit is contained in:
2026-07-05 21:58:50 +02:00
parent d808b0289f
commit cd588197b9
22 changed files with 2458 additions and 735 deletions

View File

@@ -12,6 +12,8 @@ const ready = ref(false)
onMounted(async () => {
theme.init()
// Consume an OIDC callback (?/oidc/callback#token=…) before deciding view.
app.handleOidcCallback()
await app.checkStatus()
ready.value = true
})

View File

@@ -240,14 +240,17 @@ class TipTapToAsciidoc {
nodes.forEach((t) => {
if (t.type === 'text') {
let text = t.text || ''
// Apply inline marks. Note: the previous implementation built the
// marked string but never appended it, then appended the *unmarked*
// text — silently dropping bold/italic/code/strike/link on save.
if (t.marks) {
t.marks.forEach(mark => {
t.marks.forEach((mark) => {
switch (mark.type) {
case 'bold': text = `*${text}*`; break
case 'italic': text = `_${text}_`; break
case 'strike': text = `[line-through]#${text}#`; break
case 'code': text = `\`${text}\``; break
case 'link':
case 'link': {
const url = mark.attrs?.href || ''
if (url.startsWith('http://') || url.startsWith('https://')) {
text = `${url}[${text}]`
@@ -255,17 +258,14 @@ class TipTapToAsciidoc {
text = `link:${url}[${text}]`
}
break
}
}
})
}
textOut += text
} else if (t.type === 'footnote') {
textOut += `footnote:[${t.attrs?.content || ''}]`
}
if (t.type === 'text') {
let text = t.text || ''
textOut += text
}
if (t.type === 'hardBreak') {
} else if (t.type === 'hardBreak') {
textOut += ' +\n'
}
})

View File

@@ -24,6 +24,14 @@ export const router = createRouter({
name: 'admin',
component: () => import('@/views/AdminView.vue'),
},
{
// OIDC redirect target. The token arrives in the URL fragment and is
// consumed by App.vue's onMounted before this renders; kept as an
// explicit route so the catch-all redirect does not swallow it.
path: '/oidc/callback',
name: 'oidc-callback',
component: HomeView,
},
{
path: '/:pathMatch(.*)*',
redirect: '/',

View File

@@ -8,6 +8,37 @@ export const useAppStore = defineStore('app', () => {
const token = ref<string | null>(localStorage.getItem('token'))
const username = ref<string | null>(localStorage.getItem('username'))
const role = ref<string | null>(localStorage.getItem('role'))
// Surfaced on the login page (e.g. after a denied OIDC sign-in).
const loginError = ref('')
// Redirect the browser to the backend OIDC entry point. The backend bounces
// to Authentik and, on success, back to /oidc/callback with a token fragment.
function loginWithOidc() {
window.location.href = '/auth/oidc/login'
}
// Handle the /oidc/callback route: read the token (or error) from the URL
// fragment, persist the session, and clean the address bar. Returns true if
// this was an OIDC callback navigation.
function handleOidcCallback(): boolean {
if (window.location.pathname !== '/oidc/callback') return false
const raw = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : ''
const params = new URLSearchParams(raw)
const tok = params.get('token')
const err = params.get('error')
if (tok) {
token.value = tok
username.value = params.get('username') || ''
role.value = params.get('role') || ''
localStorage.setItem('token', tok)
localStorage.setItem('username', username.value || '')
localStorage.setItem('role', role.value || '')
} else if (err) {
loginError.value = err
}
window.history.replaceState({}, '', '/')
return true
}
async function checkStatus() {
try {
@@ -95,6 +126,9 @@ export const useAppStore = defineStore('app', () => {
localStorage.removeItem('role')
}
return { requiresSetup, token, username, role, checkStatus, login, loginAsGuest, logout }
return {
requiresSetup, token, username, role, loginError,
checkStatus, login, loginAsGuest, loginWithOidc, handleOidcCallback, logout,
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,31 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { gql } from '@/lib/gql'
const app = useAppStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
const error = ref(app.loginError || '')
const options = ref({
localEnabled: true,
oidcEnabled: false,
oidcButtonLabel: 'Sign in with Authentik',
publicEnabled: true,
})
onMounted(async () => {
try {
const data = await gql<{ loginOptions: typeof options.value }>(
`{ loginOptions { localEnabled oidcEnabled oidcButtonLabel publicEnabled } }`,
)
options.value = data.loginOptions
} catch {
// fall back to defaults (local + public)
}
})
async function submit() {
error.value = ''
@@ -20,23 +39,27 @@ async function submit() {
}
}
async function continueAsGuest() {
async function continueAsPublic() {
error.value = ''
loading.value = true
try {
await app.loginAsGuest()
} catch (err: any) {
error.value = err instanceof Error ? err.message : 'Failed to sign in as guest'
error.value = err instanceof Error ? err.message : 'Failed to continue as public user'
} finally {
loading.value = false
}
}
function signInWithOidc() {
app.loginWithOidc()
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div class="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 p-8 space-y-6">
<!-- Logo -->
<div class="flex items-center justify-center gap-2 font-bold text-slate-900 dark:text-white text-xl">
<svg class="w-8 h-8 text-accent-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -48,7 +71,31 @@ async function continueAsGuest() {
<h2 class="text-center text-slate-500 dark:text-slate-400">Sign in to continue</h2>
<form @submit.prevent="submit" class="space-y-4">
<!-- OIDC / Authentik (primary when enabled) -->
<button
v-if="options.oidcEnabled"
type="button"
class="w-full flex justify-center items-center gap-2 py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-accent-600 hover:bg-accent-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500 disabled:opacity-50"
:disabled="loading"
@click="signInWithOidc"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ options.oidcButtonLabel }}
</button>
<div v-if="options.oidcEnabled && options.localEnabled" class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-slate-200 dark:border-slate-700"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-white dark:bg-slate-800 px-2 text-slate-400">or with a local account</span>
</div>
</div>
<form v-if="options.localEnabled" @submit.prevent="submit" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">Username</label>
<input
@@ -71,10 +118,6 @@ async function continueAsGuest() {
/>
</div>
<div v-if="error" class="text-sm text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400 p-3 rounded-lg border border-red-200 dark:border-red-500/20">
{{ error }}
</div>
<button
type="submit"
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-accent-600 hover:bg-accent-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500 disabled:opacity-50 disabled:cursor-not-allowed"
@@ -88,23 +131,32 @@ async function continueAsGuest() {
</button>
</form>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-slate-200 dark:border-slate-700"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-white dark:bg-slate-800 px-2 text-slate-400">or</span>
</div>
<div v-if="error" class="text-sm text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400 p-3 rounded-lg border border-red-200 dark:border-red-500/20">
{{ error }}
</div>
<button
type="button"
class="w-full flex justify-center py-2.5 px-4 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-400 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading"
@click="continueAsGuest"
>
Continue as Guest
</button>
<div v-if="options.publicEnabled">
<div class="relative mb-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-slate-200 dark:border-slate-700"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-white dark:bg-slate-800 px-2 text-slate-400">or</span>
</div>
</div>
<button
type="button"
class="w-full flex justify-center py-2.5 px-4 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-400 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading"
@click="continueAsPublic"
>
Continue as public user
</button>
<p class="text-xs text-slate-400 text-center mt-2">
The public user only sees what an administrator has shared.
</p>
</div>
</div>
</div>
</template>