Files
pve-confsync/internal/adapter/access.go
T

346 lines
11 KiB
Go

package adapter
import (
"context"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"pve-confsync/internal/model"
"pve-confsync/internal/pve"
)
// Access control objects of user.cfg, through /access (pve-access-control,
// src/PVE/API2/Group.pm, Role.pm, User.pm). Only the accounts of the pve realm
// (Proxmox VE authentication server) are replicated: pam accounts are Linux
// accounts of each node, the other realms (LDAP, AD, OpenID) are synchronized
// from their directory.
// ---- groups: /access/groups ----
type group struct{}
func (group) Kind() model.Kind { return model.KindGroup }
func (group) SDN() bool { return false }
func (group) Singleton() bool { return false }
func (group) List(ctx context.Context, c *pve.Client, _ ReadMode, _ []model.Object) ([]model.Object, []string, error) {
var raw []map[string]any
if err := c.Get(ctx, "access/groups", nil, &raw); err != nil {
return nil, nil, err
}
var objs []model.Object
for _, r := range raw {
// members come from the "groups" field of the accounts
f := dropEmpty(normalizeAPI(r, "groupid", "users"))
objs = append(objs, model.Object{Kind: model.KindGroup, ID: pve.Str(r["groupid"]), Fields: f})
}
return objs, nil, nil
}
func (group) DefaultPolicy(string) Policy { return Sync }
func (group) Canonicalize(map[string]any) {}
func (group) Forbidden(f string) bool { return f == "users" }
func (group) Immutable(string) bool { return false }
func (group) Required(model.Object) []string { return nil }
func (group) Create(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
f := form(o.Fields, nil, "")
f.Set("groupid", o.ID)
return c.Post(ctx, "access/groups", f, nil)
}
func (group) Update(ctx context.Context, c *pve.Client, t model.Object, set map[string]any, unset []string, _ string) error {
return c.Put(ctx, "access/groups/"+url.PathEscape(t.ID), form(blank(set, unset), nil, ""), nil)
}
func (group) Delete(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
return c.Delete(ctx, "access/groups/"+url.PathEscape(o.ID), nil, nil)
}
// ---- roles: /access/roles (custom roles only) ----
type role struct{}
func (role) Kind() model.Kind { return model.KindRole }
func (role) SDN() bool { return false }
func (role) Singleton() bool { return false }
func (role) List(ctx context.Context, c *pve.Client, _ ReadMode, _ []model.Object) ([]model.Object, []string, error) {
var raw []map[string]any
if err := c.Get(ctx, "access/roles", nil, &raw); err != nil {
return nil, nil, err
}
var objs []model.Object
for _, r := range raw {
if pve.Str(r["special"]) == "1" { // built-in roles (Administrator, PVEAuditor…)
continue
}
f := normalizeAPI(r, "roleid", "special")
role{}.Canonicalize(f)
objs = append(objs, model.Object{Kind: model.KindRole, ID: pve.Str(r["roleid"]), Fields: f})
}
return objs, nil, nil
}
func (role) DefaultPolicy(string) Policy { return Sync }
func (role) Canonicalize(f map[string]any) { sortList(f, "privs") }
func (role) Forbidden(string) bool { return false }
func (role) Immutable(string) bool { return false }
func (role) Required(model.Object) []string { return nil }
func (role) Create(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
f := form(o.Fields, nil, "")
f.Set("roleid", o.ID)
return c.Post(ctx, "access/roles", f, nil)
}
// Update replaces the privileges (no "append").
func (role) Update(ctx context.Context, c *pve.Client, t model.Object, set map[string]any, unset []string, _ string) error {
return c.Put(ctx, "access/roles/"+url.PathEscape(t.ID), form(blank(set, unset), nil, ""), nil)
}
func (role) Delete(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
return c.Delete(ctx, "access/roles/"+url.PathEscape(o.ID), nil, nil)
}
// ---- accounts of the pve realm: /access/users ----
// PVERealm is the realm whose accounts are replicated (type "pve").
const PVERealm = "pve"
type user struct{}
func (user) Kind() model.Kind { return model.KindUser }
func (user) SDN() bool { return false }
func (user) Singleton() bool { return false }
func (user) List(ctx context.Context, c *pve.Client, _ ReadMode, _ []model.Object) ([]model.Object, []string, error) {
var raw []map[string]any
if err := c.Get(ctx, "access/users", url.Values{"full": {"1"}}, &raw); err != nil {
return nil, nil, err
}
var objs []model.Object
for _, r := range raw {
id := pve.Str(r["userid"])
if !strings.HasSuffix(id, "@"+PVERealm) {
continue
}
// tokens hold no secret here, but are specific to each cluster (their secret is)
f := dropEmpty(normalizeAPI(r, "userid", "tokens", "realm-type", "totp-locked", "tfa-locked-until"))
user{}.Canonicalize(f)
objs = append(objs, model.Object{Kind: model.KindUser, ID: id, Fields: f})
}
return objs, nil, nil
}
func (user) DefaultPolicy(string) Policy { return Sync }
// Canonicalize: "groups" is a comma separated list; expire 0 means no expiration (absent).
func (user) Canonicalize(f map[string]any) {
if l, ok := f["groups"].([]string); ok {
f["groups"] = strings.Join(l, ",")
}
sortList(f, "groups")
if f["expire"] == "0" {
delete(f, "expire")
}
}
// Forbidden: the password is never set through the API (only its hash is copied, see password).
func (user) Forbidden(f string) bool { return f == "password" || f == "tokens" }
func (user) Immutable(string) bool { return false }
func (user) Required(model.Object) []string { return nil }
func (user) Parents(o model.Object) []string {
g, _ := o.Fields["groups"].(string)
var out []string
for _, x := range strings.Split(g, ",") {
if x = strings.TrimSpace(x); x != "" {
out = append(out, string(model.KindGroup)+"/"+x)
}
}
return out
}
func (user) Create(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
f := form(o.Fields, nil, "")
f.Set("userid", o.ID)
return c.Post(ctx, "access/users", f, nil)
}
// Update: PUT /access/users/{userid} has no "delete" parameter; a removed field
// is written empty (expire 0, groups "" removes every membership).
func (user) Update(ctx context.Context, c *pve.Client, t model.Object, set map[string]any, unset []string, _ string) error {
f := blank(set, unset)
if _, ok := f["expire"]; ok && f["expire"] == "" {
f["expire"] = "0"
}
if _, ok := f["enable"]; ok && f["enable"] == "" {
f["enable"] = "1"
}
return c.Put(ctx, "access/users/"+url.PathEscape(t.ID), form(f, nil, ""), nil)
}
func (user) Delete(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
if o.ID == "root@pam" { // never, whatever the selection
return errors.New("root@pam cannot be deleted")
}
return c.Delete(ctx, "access/users/"+url.PathEscape(o.ID), nil, nil)
}
// ---- password hashes: /etc/pve/priv/shadow.cfg over SSH ----
// The API only takes clear passwords (PUT /access/password); the hashes are
// copied as they are, so the accounts keep their password on every cluster.
// Format (src/PVE/Auth/PVE.pm): one "user:hash:" line per account, the user
// without its realm. Writes go through the pmxcfs lock of the file, like PVE
// (PVE::Auth::PVE::lock_shadow_config, cfs_read_file / cfs_write_file).
type password struct{}
const shadowRead = "cat /etc/pve/priv/shadow.cfg 2>/dev/null || true"
// shadowWrite reads "set\tuser\thash" / "del\tuser" lines on stdin: the hashes
// never appear on a command line.
const shadowWrite = `perl -e '
use strict; use warnings;
use PVE::Cluster;
use PVE::Auth::PVE;
my @ops;
while (my $l = <STDIN>) { chomp $l; push @ops, [split(/\t/, $l, 3)]; }
PVE::Auth::PVE::lock_shadow_config(sub {
my $cfg = PVE::Cluster::cfs_read_file("priv/shadow.cfg");
for my $o (@ops) {
my ($op, $u, $h) = @$o;
die "invalid user\n" if !defined($u) || $u !~ m/^[^\s:]+$/;
if ($op eq "set") {
die "invalid hash\n" if !defined($h) || $h !~ m/^[^\s:]+$/;
$cfg->{users}->{$u}->{shadow} = $h;
} elsif ($op eq "del") {
delete $cfg->{users}->{$u};
} else {
die "invalid operation\n";
}
}
PVE::Cluster::cfs_write_file("priv/shadow.cfg", $cfg);
}, "confsync");
print "ok\n";
' # confsync-shadow-write`
func (password) Kind() model.Kind { return model.KindPassword }
func (password) SDN() bool { return false }
func (password) Singleton() bool { return false }
// List reads the hashes of the known accounts of the pve realm (the accounts
// are read before, see KindDeps): orphan lines are left aside.
func (password) List(ctx context.Context, c *pve.Client, _ ReadMode, known []model.Object) ([]model.Object, []string, error) {
sh, err := c.Shell()
if err != nil {
return nil, nil, err
}
out, err := sh.Run(ctx, shadowRead, nil)
if err != nil {
return nil, nil, err
}
users := ids(known, model.KindUser)
var objs []model.Object
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
p := strings.Split(line, ":")
if len(p) != 3 || p[0] == "" || p[1] == "" {
continue
}
id := p[0] + "@" + PVERealm
if !users[id] {
continue
}
objs = append(objs, model.Object{Kind: model.KindPassword, ID: id, Fields: map[string]any{"hash": p[1]}})
}
sort.Slice(objs, func(i, j int) bool { return objs[i].ID < objs[j].ID })
return objs, nil, nil
}
func (password) DefaultPolicy(string) Policy { return Sync }
func (password) Canonicalize(map[string]any) {}
func (password) Forbidden(f string) bool { return f != "hash" }
func (password) Immutable(string) bool { return false }
func (password) Required(model.Object) []string { return []string{"hash"} }
func (password) Sensitive(f string) bool { return f == "hash" }
func (password) Parents(o model.Object) []string {
return []string{string(model.KindUser) + "/" + o.ID}
}
func shadowUser(id string) (string, error) {
u, ok := strings.CutSuffix(id, "@"+PVERealm)
if !ok || u == "" {
return "", fmt.Errorf("%s: not an account of the %s realm", id, PVERealm)
}
return u, nil
}
func (password) write(ctx context.Context, c *pve.Client, line string) error {
sh, err := c.Shell()
if err != nil {
return err
}
out, err := sh.Run(ctx, shadowWrite, []byte(line+"\n"))
if err != nil {
return err
}
if strings.TrimSpace(string(out)) != "ok" {
return errors.New("shadow.cfg: unexpected answer")
}
return nil
}
func (p password) Create(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
return p.Update(ctx, c, o, o.Fields, nil, "")
}
func (p password) Update(ctx context.Context, c *pve.Client, t model.Object, set map[string]any, _ []string, _ string) error {
u, err := shadowUser(t.ID)
if err != nil {
return err
}
h, _ := set["hash"].(string)
if h == "" {
return errors.New("empty password hash")
}
return p.write(ctx, c, "set\t"+u+"\t"+h)
}
func (p password) Delete(ctx context.Context, c *pve.Client, o model.Object, _ string) error {
u, err := shadowUser(o.ID)
if err != nil {
return err
}
return p.write(ctx, c, "del\t"+u)
}
// ---- helpers ----
// dropEmpty removes empty values: PVE returns some optional fields empty.
func dropEmpty(f map[string]any) map[string]any {
for k, v := range f {
if v == "" {
delete(f, k)
}
}
return f
}
// blank merges the fields to remove as empty values (APIs without "delete").
func blank(set map[string]any, unset []string) map[string]any {
out := make(map[string]any, len(set)+len(unset))
for k, v := range set {
out[k] = v
}
for _, k := range unset {
out[k] = ""
}
return out
}