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, ¬null, &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, } } 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 }