Files
Archivum/backend/internal/db/db.go
Bjorn Blomberg 9180033543
Some checks failed
build-and-push / build (push) Failing after 1h0m8s
feat(acl): tree-based access editor + permission-aware UI
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) <noreply@anthropic.com>
2026-07-07 20:28:16 +02:00

750 lines
22 KiB
Go

package db
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
_ "modernc.org/sqlite"
)
var ErrUserNotFound = errors.New("user not found")
var ErrUserExists = errors.New("username already exists")
// Built-in group names that map to application roles. They are seeded on
// startup and are what OIDC group claims are matched against.
const (
GroupAdmin = "Archivum-admin"
GroupReader = "Archivum-reader"
)
// User represents an account stored in SQLite (local or externally
// provisioned via OIDC/LDAP).
type User struct {
ID int64
Username string
PassHash string
Role string
IsLDAP bool
AllowLogin bool
CreatedAt time.Time
}
// Group represents a group stored in SQLite.
type Group struct {
ID int64
Name string
IsLDAP bool
AllowLogin bool
}
// DB wraps the SQLite connection.
type DB struct {
sql *sql.DB
}
// New opens (or creates) the SQLite database at path and runs migrations.
func New(path string) (*DB, error) {
sqldb, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000&_pragma=foreign_keys(1)")
if err != nil {
return nil, err
}
d := &DB{sql: sqldb}
if err := d.init(); err != nil {
sqldb.Close()
return nil, err
}
return d, nil
}
func (d *DB) Close() error {
return d.sql.Close()
}
func (d *DB) init() error {
_, err := d.sql.Exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
pass_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
is_ldap BOOLEAN NOT NULL DEFAULT 0,
allow_login BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
is_ldap BOOLEAN NOT NULL DEFAULT 0,
allow_login BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_groups (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY(user_id, group_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS acl (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
subject_type TEXT NOT NULL CHECK(subject_type IN ('user', 'group')),
subject_id INTEGER NOT NULL,
effect TEXT NOT NULL DEFAULT 'allow' CHECK(effect IN ('allow','deny')),
can_search BOOLEAN NOT NULL DEFAULT 0,
can_view BOOLEAN NOT NULL DEFAULT 0,
can_read BOOLEAN NOT NULL DEFAULT 0,
can_edit BOOLEAN NOT NULL DEFAULT 0,
can_create BOOLEAN NOT NULL DEFAULT 0,
can_delete BOOLEAN NOT NULL DEFAULT 0,
can_move BOOLEAN NOT NULL DEFAULT 0,
UNIQUE(path, subject_type, subject_id, effect)
);
`)
if err != nil {
return err
}
return d.migrate()
}
// migrate brings older databases (created before allow/deny ACLs and the
// allow_login flags) up to the current schema. All steps are idempotent.
func (d *DB) migrate() error {
// users.allow_login
if ok, err := d.columnExists("users", "allow_login"); err != nil {
return err
} else if !ok {
if _, err := d.sql.Exec(`ALTER TABLE users ADD COLUMN allow_login BOOLEAN NOT NULL DEFAULT 1`); err != nil {
return err
}
}
// groups.allow_login
if ok, err := d.columnExists("groups", "allow_login"); err != nil {
return err
} else if !ok {
if _, err := d.sql.Exec(`ALTER TABLE groups ADD COLUMN allow_login BOOLEAN NOT NULL DEFAULT 0`); err != nil {
return err
}
}
// acl.effect — requires a table rebuild because the UNIQUE constraint changes.
if ok, err := d.columnExists("acl", "effect"); err != nil {
return err
} else if !ok {
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmts := []string{
`ALTER TABLE acl RENAME TO acl_old`,
`CREATE TABLE acl (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
subject_type TEXT NOT NULL CHECK(subject_type IN ('user', 'group')),
subject_id INTEGER NOT NULL,
effect TEXT NOT NULL DEFAULT 'allow' CHECK(effect IN ('allow','deny')),
can_search BOOLEAN NOT NULL DEFAULT 0,
can_view BOOLEAN NOT NULL DEFAULT 0,
can_read BOOLEAN NOT NULL DEFAULT 0,
can_edit BOOLEAN NOT NULL DEFAULT 0,
can_create BOOLEAN NOT NULL DEFAULT 0,
can_delete BOOLEAN NOT NULL DEFAULT 0,
can_move BOOLEAN NOT NULL DEFAULT 0,
UNIQUE(path, subject_type, subject_id, effect)
)`,
`INSERT INTO acl (path, subject_type, subject_id, effect, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move)
SELECT path, subject_type, subject_id, 'allow', can_search, can_view, can_read, can_edit, can_create, can_delete, can_move FROM acl_old`,
`DROP TABLE acl_old`,
}
for _, s := range stmts {
if _, err := tx.Exec(s); err != nil {
return fmt.Errorf("acl migration: %w", err)
}
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
func (d *DB) columnExists(table, column string) (bool, error) {
rows, err := d.sql.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
// HasUsers returns true if at least one non-guest user account exists.
func (d *DB) HasUsers() bool {
var n int
d.sql.QueryRow(`SELECT COUNT(*) FROM users WHERE username != 'guest'`).Scan(&n)
return n > 0
}
// ── Users ───────────────────────────────────────────────────────────────────
// CreateUser inserts a new local user. Returns ErrUserExists if taken.
func (d *DB) CreateUser(username, passHash, role string) error {
_, err := d.sql.Exec(
`INSERT INTO users (username, pass_hash, role, allow_login) VALUES (?, ?, ?, 1)`,
username, passHash, role,
)
if err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") {
return ErrUserExists
}
return err
}
// GetUser fetches a single user by username.
func (d *DB) GetUser(username string) (*User, error) {
row := d.sql.QueryRow(
`SELECT id, username, pass_hash, role, is_ldap, allow_login, created_at FROM users WHERE username = ?`,
username,
)
u := &User{}
var createdAt string
err := row.Scan(&u.ID, &u.Username, &u.PassHash, &u.Role, &u.IsLDAP, &u.AllowLogin, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
if err != nil {
return nil, err
}
u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
return u, nil
}
// ListUsers returns all users ordered by id.
func (d *DB) ListUsers() ([]*User, error) {
rows, err := d.sql.Query(
`SELECT id, username, role, is_ldap, allow_login, created_at FROM users ORDER BY id`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var users []*User
for rows.Next() {
u := &User{}
var createdAt string
if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.IsLDAP, &u.AllowLogin, &createdAt); err != nil {
return nil, err
}
u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
users = append(users, u)
}
return users, rows.Err()
}
// DeleteUser removes a user. Returns ErrUserNotFound if they don't exist.
func (d *DB) DeleteUser(username string) error {
res, err := d.sql.Exec(`DELETE FROM users WHERE username = ?`, username)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrUserNotFound
}
return nil
}
// UpdatePassword replaces the stored hash for a user.
func (d *DB) UpdatePassword(username, passHash string) error {
res, err := d.sql.Exec(
`UPDATE users SET pass_hash = ? WHERE username = ?`,
passHash, username,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrUserNotFound
}
return nil
}
// SetUserRole updates a user's role (admin/user/guest).
func (d *DB) SetUserRole(username, role string) error {
_, err := d.sql.Exec(`UPDATE users SET role = ? WHERE username = ?`, role, username)
return err
}
// SetUserLogin toggles whether a user is permitted to sign in.
func (d *DB) SetUserLogin(username string, allow bool) error {
_, err := d.sql.Exec(`UPDATE users SET allow_login = ? WHERE username = ?`, boolInt(allow), username)
return err
}
// UserCount returns the total number of accounts.
func (d *DB) UserCount() (int, error) {
var n int
err := d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
// ── LDAP / OIDC provisioning ─────────────────────────────────────────────────
// CreateOrUpdateExternalUser inserts or updates an externally-authenticated
// user (OIDC/LDAP; no local password). On first insert allow_login is set to
// allowLogin; on subsequent logins the stored allow_login is preserved so an
// admin's manual toggle sticks. Existing accounts with a non-empty pass_hash
// (local admins) keep their role — they are never downgraded.
func (d *DB) CreateOrUpdateExternalUser(username, role string, allowLogin bool) error {
if role == "" {
role = "user"
}
_, err := d.sql.Exec(`
INSERT INTO users (username, pass_hash, role, is_ldap, allow_login)
VALUES (?, '', ?, 1, ?)
ON CONFLICT(username) DO UPDATE SET is_ldap=1, role=excluded.role
WHERE users.pass_hash = '';
`, username, role, boolInt(allowLogin))
return err
}
// CreateOrUpdateGroup inserts or updates a group (local or external).
func (d *DB) CreateOrUpdateGroup(name string, isLdap bool) error {
_, err := d.sql.Exec(`
INSERT INTO groups (name, is_ldap)
VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET is_ldap=excluded.is_ldap;
`, name, boolInt(isLdap))
return err
}
// DeleteGroup removes a group and its memberships/ACLs cascade via FK / manual cleanup.
func (d *DB) DeleteGroup(name string) error {
g, err := d.GetGroup(name)
if err != nil {
return err
}
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM acl WHERE subject_type='group' AND subject_id=?`, g.ID); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM groups WHERE id=?`, g.ID); err != nil {
return err
}
return tx.Commit()
}
// SetGroupLogin toggles whether members of a group are permitted to sign in.
func (d *DB) SetGroupLogin(name string, allow bool) error {
_, err := d.sql.Exec(`UPDATE groups SET allow_login = ? WHERE name = ?`, boolInt(allow), name)
return err
}
// GetGroup fetches a group by name.
func (d *DB) GetGroup(name string) (*Group, error) {
row := d.sql.QueryRow(`SELECT id, name, is_ldap, allow_login FROM groups WHERE name = ?`, name)
g := &Group{}
if err := row.Scan(&g.ID, &g.Name, &g.IsLDAP, &g.AllowLogin); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, err
}
return g, nil
}
// ListGroups returns all groups ordered by name.
func (d *DB) ListGroups() ([]*Group, error) {
rows, err := d.sql.Query(`SELECT id, name, is_ldap, allow_login FROM groups ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var groups []*Group
for rows.Next() {
g := &Group{}
if err := rows.Scan(&g.ID, &g.Name, &g.IsLDAP, &g.AllowLogin); err != nil {
return nil, err
}
groups = append(groups, g)
}
return groups, rows.Err()
}
// EnsureBuiltinGroups creates the Archivum-admin / Archivum-reader groups that
// map to application roles. They are marked external (synced from the IdP) and
// login-enabled.
func (d *DB) EnsureBuiltinGroups() error {
for _, name := range []string{GroupAdmin, GroupReader} {
if _, err := d.sql.Exec(`
INSERT INTO groups (name, is_ldap, allow_login) VALUES (?, 1, 1)
ON CONFLICT(name) DO UPDATE SET allow_login=1;
`, name); err != nil {
return err
}
}
return nil
}
// EnsureGuestUser creates the built-in public/guest account if missing.
func (d *DB) EnsureGuestUser() error {
_, err := d.sql.Exec(`
INSERT INTO users (username, pass_hash, role, allow_login) VALUES ('guest', '', 'guest', 1)
ON CONFLICT(username) DO NOTHING;
`)
return err
}
// ── Group membership ──────────────────────────────────────────────────────────
// AddUserToGroup links a user to a group (both by name).
func (d *DB) AddUserToGroup(username, groupName string) error {
u, err := d.GetUser(username)
if err != nil {
return err
}
g, err := d.GetGroup(groupName)
if err != nil {
return err
}
_, err = d.sql.Exec(`INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)`, u.ID, g.ID)
return err
}
// RemoveUserFromGroup unlinks a user from a group.
func (d *DB) RemoveUserFromGroup(username, groupName string) error {
u, err := d.GetUser(username)
if err != nil {
return err
}
g, err := d.GetGroup(groupName)
if err != nil {
return err
}
_, err = d.sql.Exec(`DELETE FROM user_groups WHERE user_id=? AND group_id=?`, u.ID, g.ID)
return err
}
// GetUserGroupNames returns the names of all groups a (local) user belongs to.
func (d *DB) GetUserGroupNames(username string) ([]string, error) {
rows, err := d.sql.Query(`
SELECT g.name FROM groups g
JOIN user_groups ug ON ug.group_id = g.id
JOIN users u ON u.id = ug.user_id
WHERE u.username = ?
ORDER BY g.name
`, username)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
// SyncUserGroups ensures the given group names exist and that the user is a
// member of exactly those groups that are also present in the DB. Used to keep
// a local mirror of an OIDC user's group membership for the admin UI.
func (d *DB) SyncUserGroups(username string, groupNames []string) error {
u, err := d.GetUser(username)
if err != nil {
return err
}
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM user_groups WHERE user_id=?`, u.ID); err != nil {
return err
}
for _, name := range groupNames {
var gid int64
if err := tx.QueryRow(`SELECT id FROM groups WHERE name=?`, name).Scan(&gid); err != nil {
if errors.Is(err, sql.ErrNoRows) {
continue
}
return err
}
if _, err := tx.Exec(`INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)`, u.ID, gid); err != nil {
return err
}
}
return tx.Commit()
}
// ── Login gate ────────────────────────────────────────────────────────────────
// LoginAllowed reports whether a subject may sign in: the user must have
// allow_login set, or belong to at least one login-enabled group (by name).
// The built-in role groups always permit login.
func (d *DB) LoginAllowed(username string, groupNames []string) bool {
if u, err := d.GetUser(username); err == nil {
if u.Role == "admin" || u.AllowLogin {
return true
}
}
for _, name := range groupNames {
if name == GroupAdmin || name == GroupReader {
return true
}
var allow int
d.sql.QueryRow(`SELECT allow_login FROM groups WHERE name=?`, name).Scan(&allow)
if allow == 1 {
return true
}
}
return false
}
// ── ACL ────────────────────────────────────────────────────────────────────
type ACLEntry struct {
ID int64
Path string
SubjectType string
SubjectID int64
Effect string // "allow" | "deny"
CanSearch bool
CanView bool
CanRead bool
CanEdit bool
CanCreate bool
CanDelete bool
CanMove bool
}
// Perms is the resolved effective permission set for a subject on a path.
type Perms struct {
Search bool
View bool
Read bool
Edit bool
Create bool
Delete bool
Move bool
}
// SetACL inserts or replaces an ACL entry keyed on (path, subject, effect).
func (d *DB) SetACL(entry ACLEntry) error {
if entry.Effect != "deny" {
entry.Effect = "allow"
}
_, err := d.sql.Exec(`
INSERT INTO acl (path, subject_type, subject_id, effect, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(path, subject_type, subject_id, effect) DO UPDATE SET
can_search = excluded.can_search,
can_view = excluded.can_view,
can_read = excluded.can_read,
can_edit = excluded.can_edit,
can_create = excluded.can_create,
can_delete = excluded.can_delete,
can_move = excluded.can_move;
`, entry.Path, entry.SubjectType, entry.SubjectID, entry.Effect,
entry.CanSearch, entry.CanView, entry.CanRead, entry.CanEdit,
entry.CanCreate, entry.CanDelete, entry.CanMove)
return err
}
// RemoveACL deletes an ACL entry by ID.
func (d *DB) RemoveACL(id int64) error {
_, err := d.sql.Exec(`DELETE FROM acl WHERE id = ?`, id)
return err
}
func scanACLRows(rows *sql.Rows) ([]ACLEntry, error) {
defer rows.Close()
var entries []ACLEntry
for rows.Next() {
var e ACLEntry
if err := rows.Scan(
&e.ID, &e.Path, &e.SubjectType, &e.SubjectID, &e.Effect,
&e.CanSearch, &e.CanView, &e.CanRead, &e.CanEdit,
&e.CanCreate, &e.CanDelete, &e.CanMove,
); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
const aclCols = `id, path, subject_type, subject_id, effect, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move`
// GetACLsForSubject retrieves all ACL entries for a specific subject.
func (d *DB) GetACLsForSubject(subjectType string, subjectID int64) ([]ACLEntry, error) {
rows, err := d.sql.Query(
`SELECT `+aclCols+` FROM acl WHERE subject_type = ? AND subject_id = ? ORDER BY path, effect`,
subjectType, subjectID)
if err != nil {
return nil, err
}
return scanACLRows(rows)
}
// GetACLsForPath retrieves all ACL definitions for a specific path.
func (d *DB) GetACLsForPath(path string) ([]ACLEntry, error) {
rows, err := d.sql.Query(`SELECT `+aclCols+` FROM acl WHERE path = ?`, path)
if err != nil {
return nil, err
}
return scanACLRows(rows)
}
// EffectiveAccess resolves the permissions a user has on a path by combining
// every ACL entry that applies to the user directly or to any of the given
// groups, across the path and all of its ancestor folders.
//
// Resolution rule (as specified): default deny. An explicit allow grants a
// permission; an explicit deny anywhere in the applicable set always wins over
// an allow. So a permission is granted only if some entry allows it and no
// entry denies it.
func (d *DB) EffectiveAccess(username string, groupNames []string, path string) Perms {
ancestors := pathAncestors(path)
ancestorSet := make(map[string]bool, len(ancestors))
for _, p := range ancestors {
ancestorSet[p] = true
}
// Build the subject filter: this user + these groups.
var where []string
var args []interface{}
if uid, ok := d.userID(username); ok {
where = append(where, `(subject_type='user' AND subject_id=?)`)
args = append(args, uid)
}
if gids := d.groupIDs(groupNames); len(gids) > 0 {
ph := make([]string, len(gids))
for i, id := range gids {
ph[i] = "?"
args = append(args, id)
}
where = append(where, `(subject_type='group' AND subject_id IN (`+strings.Join(ph, ",")+`))`)
}
if len(where) == 0 {
return Perms{}
}
rows, err := d.sql.Query(
`SELECT `+aclCols+` FROM acl WHERE `+strings.Join(where, " OR "), args...)
if err != nil {
return Perms{}
}
entries, err := scanACLRows(rows)
if err != nil {
return Perms{}
}
var allow, deny Perms
for _, e := range entries {
if !ancestorSet[e.Path] {
continue
}
target := &allow
if e.Effect == "deny" {
target = &deny
}
target.Search = target.Search || e.CanSearch
target.View = target.View || e.CanView
target.Read = target.Read || e.CanRead
target.Edit = target.Edit || e.CanEdit
target.Create = target.Create || e.CanCreate
target.Delete = target.Delete || e.CanDelete
target.Move = target.Move || e.CanMove
}
return Perms{
Search: allow.Search && !deny.Search,
View: allow.View && !deny.View,
Read: allow.Read && !deny.Read,
Edit: allow.Edit && !deny.Edit,
Create: allow.Create && !deny.Create,
Delete: allow.Delete && !deny.Delete,
Move: allow.Move && !deny.Move,
}
}
// 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)
if err != nil {
return 0, false
}
return id, true
}
func (d *DB) groupIDs(names []string) []int64 {
var out []int64
for _, n := range names {
var id int64
if err := d.sql.QueryRow(`SELECT id FROM groups WHERE name=?`, n).Scan(&id); err == nil {
out = append(out, id)
}
}
return out
}
// pathAncestors returns the path and all its parent segments.
// e.g. "a/b/c" → ["a/b/c", "a/b", "a"]
func pathAncestors(path string) []string {
path = strings.TrimSuffix(path, "/")
out := []string{path}
for {
idx := strings.LastIndex(path, "/")
if idx < 0 {
break
}
path = path[:idx]
out = append(out, path)
}
return out
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}