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

@@ -19,6 +19,7 @@ type Session struct {
Username string
Token string
Role string
Groups []string // group names (from OIDC claim or local membership)
ExpiresAt time.Time
}
@@ -59,9 +60,10 @@ func CheckPassword(hashedPassword, password string) error {
// Returns the session token, the user's role, and any error.
// password is a SHA-256 hash (for local accounts); ldapPassword is plaintext (for LDAP bind).
func (m *Manager) Login(database *db.DB, username, password, ldapPassword string) (token, role string, err error) {
// Guest login — no password required.
// Guest / public login — no password required.
if username == "guest" {
tok, err := m.createSession("guest", "guest")
groups, _ := database.GetUserGroupNames("guest")
tok, err := m.createSession("guest", "guest", groups)
return tok, "guest", err
}
@@ -74,7 +76,8 @@ func (m *Manager) Login(database *db.DB, username, password, ldapPassword string
if bcrypt.CompareHashAndPassword([]byte(user.PassHash), []byte(password)) != nil {
return "", "", errors.New("invalid credentials")
}
tok, err := m.createSession(username, user.Role)
groups, _ := database.GetUserGroupNames(username)
tok, err := m.createSession(username, user.Role, groups)
return tok, user.Role, err
}
// No local password — must be an LDAP-only account.
@@ -92,7 +95,8 @@ func (m *Manager) Login(database *db.DB, username, password, ldapPassword string
if err := ldapUserBind(cfg, username, ldapPwd); err != nil {
return "", "", errors.New("invalid credentials")
}
tok, err := m.createSession(username, user.Role)
groups, _ := database.GetUserGroupNames(username)
tok, err := m.createSession(username, user.Role, groups)
return tok, user.Role, err
}
// In DB but no password and not LDAP — refuse.
@@ -116,10 +120,60 @@ func (m *Manager) Login(database *db.DB, username, password, ldapPassword string
return "", "", err
}
tok, err := m.createSession(username, "user")
tok, err := m.createSession(username, "user", nil)
return tok, "user", err
}
// LoginOIDC establishes a session from a verified OIDC identity. It maps the
// group claim to a role (admin group → admin, otherwise user), mirrors the
// user and groups into the local DB (for the admin UI and ACL targeting), and
// enforces the login allow-list. Returns an error if the account is not
// permitted to sign in.
func (m *Manager) LoginOIDC(database *db.DB, u *OIDCUser) (token, role string, err error) {
m.mu.RLock()
cfg := m.cfg
m.mu.RUnlock()
adminGroup, readerGroup := "Archivum-admin", "Archivum-reader"
if cfg != nil {
oc := cfg.OIDC
oc.Normalize()
adminGroup, readerGroup = oc.AdminGroup, oc.ReaderGroup
}
_ = readerGroup // reader currently maps to the ACL-gated "user" role
// Mirror the token's groups locally so ACLs can target them and the admin
// UI can list them.
for _, g := range u.Groups {
_ = database.CreateOrUpdateGroup(g, true)
}
allowed := database.LoginAllowed(u.Username, u.Groups)
role = "user"
for _, g := range u.Groups {
if g == adminGroup {
role = "admin"
break
}
}
// Provision / refresh the user record and its group mirror. allow_login is
// seeded from the gate result on first insert and preserved afterwards.
_ = database.CreateOrUpdateExternalUser(u.Username, role, allowed)
if role == "admin" {
_ = database.SetUserRole(u.Username, "admin")
}
_ = database.SyncUserGroups(u.Username, u.Groups)
if !allowed {
return "", "", errors.New("this account is not permitted to sign in to Archivum")
}
tok, err := m.createSession(u.Username, role, u.Groups)
return tok, role, err
}
// UserAuthType returns "guest", "ldap", or "local" for the given username.
// A user with a non-empty pass_hash is always "local", regardless of is_ldap,
// so that the frontend hashes the password before sending it.
@@ -246,7 +300,7 @@ func BrowseLDAP(url, baseDN, adminUser, adminPassword string) ([]string, []strin
return users, groups, nil
}
func (m *Manager) createSession(username, role string) (string, error) {
func (m *Manager) createSession(username, role string, groups []string) (string, error) {
token, err := generateToken()
if err != nil {
return "", err
@@ -256,6 +310,7 @@ func (m *Manager) createSession(username, role string) (string, error) {
Username: username,
Token: token,
Role: role,
Groups: groups,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
m.mu.Unlock()