Quellcode durchsuchen

Add cluster identity and auth forwarding

Introduce cluster identity support with an origin node that verifies logins through a new auth forward hook, mirrors account hashes and groups to members for offline fallback, and writes password changes back to the origin. This also adds signed cross-node user assertions, persists identity state in cluster.db, replicates cluster-wide identity settings through membership gossip, and exposes identity controls/status in the SystemAO cluster UI and docs.
Toby Chui vor 2 Wochen
Ursprung
Commit
f1ffb6a2b5

+ 3 - 0
.gitignore

@@ -24,6 +24,9 @@ src/files
 src/system/logs 
 src/system/neuralnet/yolov3.weights
 src/system/ao.db.lock
+src/system/cluster.db
+src/system/cluster.db.lock
+src/system/cluster/
 src/system/auth/authlog.db.lock
 src/system/neuralnet/predictions.jpg
 src/system/auth/authlog.db

+ 7 - 0
CLAUDE.md

@@ -266,6 +266,13 @@ The runtime lives in [`src/mod/cluster/`](src/mod/cluster/) and is documented in
   Admin API `/system/cluster/*` and the System Settings page
   [`src/web/SystemAO/cluster/cluster.html`](src/web/SystemAO/cluster/cluster.html);
   wiring in [`src/cluster.go`](src/cluster.go).
+- **Identity** ([`src/mod/cluster/identity/`](src/mod/cluster/identity/)):
+  one member is the *identity origin*; other members forward logins to it
+  through the auth agent's `ForwardAuth` hook (password hash only), mirror the
+  account locally, pull the origin's account directory as a fallback when the
+  origin is unreachable, and write password changes back. Cross-node requests
+  carry signed user assertions (`X-Aroz-User`). Password changes in core code
+  must call `clusterNotifyPasswordChanged` after writing the hash.
 - **State** lives in its own key-value file `system/cluster.db` (never `ao.db`)
   and the node key in `system/cluster/node.key`.
 - **Design rules:** whole files, never chunked storage; cross-node transfers in

+ 54 - 1
src/cluster.go

@@ -7,6 +7,7 @@ import (
 	"time"
 
 	"imuslab.com/arozos/mod/cluster/capability"
+	"imuslab.com/arozos/mod/cluster/identity"
 	"imuslab.com/arozos/mod/cluster/membership"
 	"imuslab.com/arozos/mod/info/usageinfo"
 	"imuslab.com/arozos/mod/network/neighbour"
@@ -28,8 +29,42 @@ import (
 var (
 	NeighbourDiscoverer *neighbour.Discoverer
 	clusterManager      *membership.Manager
+	clusterIdentity     *identity.Manager
 )
 
+// clusterAccountStore adapts the auth agent and permission handler to the
+// identity service's AccountStore interface.
+type clusterAccountStore struct{}
+
+func (s *clusterAccountStore) UserExists(username string) bool { return authAgent.UserExists(username) }
+func (s *clusterAccountStore) PasswordHash(username string) (string, error) {
+	return authAgent.GetPasswordHash(username)
+}
+func (s *clusterAccountStore) SetPasswordHash(username string, hash string) error {
+	return authAgent.SetPasswordHash(username, hash)
+}
+func (s *clusterAccountStore) Groups(username string) ([]string, error) {
+	return authAgent.GetUserGroups(username)
+}
+func (s *clusterAccountStore) SetGroups(username string, groups []string) error {
+	return authAgent.SetUserGroups(username, groups)
+}
+func (s *clusterAccountStore) ListUsers() []string { return authAgent.ListUsers() }
+func (s *clusterAccountStore) DeleteUser(username string) error {
+	return authAgent.UnregisterUser(username)
+}
+func (s *clusterAccountStore) GroupExists(group string) bool {
+	return permissionHandler.GroupExists(group)
+}
+
+// clusterNotifyPasswordChanged forwards a password change of a replicated
+// account to the identity origin. Safe to call when clustering is off.
+func clusterNotifyPasswordChanged(username string, passwordHash string) {
+	if clusterIdentity != nil {
+		clusterIdentity.NotifyPasswordChanged(username, passwordHash)
+	}
+}
+
 // clusterHealthProvider builds the load snapshot shipped with each heartbeat.
 // RAM figures are cached because reading them shells out on some platforms.
 type clusterHealthProvider struct {
@@ -85,9 +120,24 @@ func ClusterInit() {
 				errorHandlePermissionDenied(w, r)
 			},
 		})
-		clusterManager.RegisterAdminRoutes(func(pattern string, handler func(http.ResponseWriter, *http.Request)) {
+		registerAdmin := func(pattern string, handler func(http.ResponseWriter, *http.Request)) {
 			adminRouter.HandleFunc(pattern, handler)
+		}
+		clusterManager.RegisterAdminRoutes(registerAdmin)
+
+		//Identity service: forward auth to the cluster's identity origin with
+		//replicated accounts as fallback, plus signed user assertions
+		idm, err := identity.New(identity.Option{
+			Membership: clusterManager,
+			Accounts:   &clusterAccountStore{},
 		})
+		if err != nil {
+			systemWideLogger.PrintAndLog("Cluster", "Unable to start cluster identity service: "+err.Error(), err)
+		} else {
+			clusterIdentity = idm
+			authAgent.ForwardAuth = clusterIdentity.ForwardAuth
+			clusterIdentity.RegisterAdminRoutes(registerAdmin)
+		}
 
 		registerSetting(settingModule{
 			Name:         "Cluster",
@@ -141,6 +191,9 @@ func ClusterInit() {
 
 // ClusterShutdown stops heartbeats and tunnels before the process exits.
 func ClusterShutdown() {
+	if clusterIdentity != nil {
+		clusterIdentity.Close()
+	}
 	if clusterManager != nil {
 		clusterManager.Close()
 	}

+ 5 - 2
src/mod/auth/auth.go

@@ -69,6 +69,9 @@ type AuthAgent struct {
 	//Account Switcher
 	SwitchableAccountManager *SwitchableAccountPoolManager
 
+	//Optional external login decision (cluster identity), consulted before the local table
+	ForwardAuth ForwardAuthHandler
+
 	//Logger
 	Logger *authlogger.Logger
 }
@@ -211,8 +214,8 @@ func (a *AuthAgent) HandleLogin(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	//Check the database and see if this user is in the database
-	passwordCorrect, rejectionReason := a.ValidateUsernameAndPasswordWithReason(username, password)
+	//Decide the login: forward auth hook (cluster identity) first, then the local database
+	passwordCorrect, rejectionReason := a.validateLogin(username, password)
 	//The database contain this user information. Check its password if it is correct
 	if passwordCorrect {
 		//Password correct

+ 96 - 0
src/mod/auth/forwardauth.go

@@ -0,0 +1,96 @@
+package auth
+
+/*
+	Forward authentication hook and account primitives
+
+	A ForwardAuthHandler lets another subsystem (the cluster identity
+	service) decide a login before the local password table is consulted.
+	It receives the SHA-512 password hash, never the clear text, so it can be
+	compared against the hashes stored on a remote node without shipping the
+	password around.
+
+	The account primitives below expose the auth table in terms of hashes and
+	group lists so accounts can be replicated between nodes as-is.
+*/
+
+import (
+	"crypto/subtle"
+	"errors"
+)
+
+// ForwardAuthResult is the verdict of a ForwardAuthHandler.
+type ForwardAuthResult struct {
+	Decided  bool   //false means "no opinion", fall back to the local password table
+	Accepted bool   //valid only when Decided
+	Reason   string //shown to the user when Decided and not Accepted
+}
+
+// ForwardAuthHandler decides a login attempt from the username and the
+// SHA-512 hash of the supplied password.
+type ForwardAuthHandler func(username string, passwordHash string) ForwardAuthResult
+
+// ValidateUsernameAndPasswordHash checks an already hashed password.
+func (a *AuthAgent) ValidateUsernameAndPasswordHash(username string, passwordHash string) bool {
+	stored, err := a.GetPasswordHash(username)
+	if err != nil || stored == "" {
+		return false
+	}
+	return subtle.ConstantTimeCompare([]byte(stored), []byte(passwordHash)) == 1
+}
+
+// GetPasswordHash returns the stored password hash of a user.
+func (a *AuthAgent) GetPasswordHash(username string) (string, error) {
+	hash := ""
+	if err := a.Database.Read("auth", "passhash/"+username, &hash); err != nil {
+		return "", err
+	}
+	if hash == "" {
+		return "", errors.New("user not found")
+	}
+	return hash, nil
+}
+
+// SetPasswordHash stores an already hashed password for a user, creating the
+// password entry when it does not exist yet.
+func (a *AuthAgent) SetPasswordHash(username string, passwordHash string) error {
+	if username == "" || passwordHash == "" {
+		return errors.New("username and password hash are required")
+	}
+	return a.Database.Write("auth", "passhash/"+username, passwordHash)
+}
+
+// GetUserGroups returns the permission group names of a user.
+func (a *AuthAgent) GetUserGroups(username string) ([]string, error) {
+	if !a.Database.KeyExists("auth", "group/"+username) {
+		return nil, errors.New("user not found")
+	}
+	groups := []string{}
+	if err := a.Database.Read("auth", "group/"+username, &groups); err != nil {
+		return nil, err
+	}
+	if groups == nil {
+		groups = []string{}
+	}
+	return groups, nil
+}
+
+// SetUserGroups replaces the permission group names of a user.
+func (a *AuthAgent) SetUserGroups(username string, groups []string) error {
+	if username == "" {
+		return errors.New("username is required")
+	}
+	if groups == nil {
+		groups = []string{}
+	}
+	return a.Database.Write("auth", "group/"+username, groups)
+}
+
+// validateLogin decides a login, consulting the forward auth hook first.
+func (a *AuthAgent) validateLogin(username string, password string) (bool, string) {
+	if a.ForwardAuth != nil {
+		if res := a.ForwardAuth(username, Hash(password)); res.Decided {
+			return res.Accepted, res.Reason
+		}
+	}
+	return a.ValidateUsernameAndPasswordWithReason(username, password)
+}

+ 134 - 0
src/mod/auth/forwardauth_test.go

@@ -0,0 +1,134 @@
+package auth
+
+import (
+	"path/filepath"
+	"testing"
+
+	db "imuslab.com/arozos/mod/database"
+)
+
+// newHashOnlyAgent builds a minimal agent backed by a temporary database,
+// enough for the account primitives and login decision logic.
+func newHashOnlyAgent(t *testing.T) *AuthAgent {
+	t.Helper()
+	sysdb, err := db.NewDatabase(filepath.Join(t.TempDir(), "ao.db"), false)
+	if err != nil {
+		t.Fatalf("NewDatabase: %v", err)
+	}
+	if err := sysdb.NewTable("auth"); err != nil {
+		t.Fatalf("NewTable: %v", err)
+	}
+	t.Cleanup(sysdb.Close)
+	return &AuthAgent{Database: sysdb}
+}
+
+func TestPasswordHashPrimitives(t *testing.T) {
+	a := newHashOnlyAgent(t)
+	if _, err := a.GetPasswordHash("nobody"); err == nil {
+		t.Errorf("missing user should error")
+	}
+	if err := a.SetPasswordHash("", Hash("x")); err == nil {
+		t.Errorf("empty username accepted")
+	}
+	if err := a.SetPasswordHash("toby", ""); err == nil {
+		t.Errorf("empty hash accepted")
+	}
+	if err := a.SetPasswordHash("toby", Hash("secret")); err != nil {
+		t.Fatalf("SetPasswordHash: %v", err)
+	}
+	got, err := a.GetPasswordHash("toby")
+	if err != nil || got != Hash("secret") {
+		t.Errorf("GetPasswordHash = %q, %v", got, err)
+	}
+
+	tests := []struct {
+		name string
+		user string
+		hash string
+		want bool
+	}{
+		{"correct", "toby", Hash("secret"), true},
+		{"wrong", "toby", Hash("nope"), false},
+		{"unknown user", "alice", Hash("secret"), false},
+		{"empty hash", "toby", "", false},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := a.ValidateUsernameAndPasswordHash(tc.user, tc.hash); got != tc.want {
+				t.Errorf("got %v want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestUserGroupPrimitives(t *testing.T) {
+	a := newHashOnlyAgent(t)
+	if _, err := a.GetUserGroups("nobody"); err == nil {
+		t.Errorf("missing user should error")
+	}
+	if err := a.SetUserGroups("", []string{"a"}); err == nil {
+		t.Errorf("empty username accepted")
+	}
+	if err := a.SetUserGroups("toby", nil); err != nil {
+		t.Fatalf("SetUserGroups(nil): %v", err)
+	}
+	groups, err := a.GetUserGroups("toby")
+	if err != nil || len(groups) != 0 {
+		t.Errorf("nil groups should read back empty, got %v %v", groups, err)
+	}
+	if err := a.SetUserGroups("toby", []string{"administrator", "photographers"}); err != nil {
+		t.Fatalf("SetUserGroups: %v", err)
+	}
+	groups, _ = a.GetUserGroups("toby")
+	if len(groups) != 2 || groups[0] != "administrator" {
+		t.Errorf("groups not stored: %v", groups)
+	}
+}
+
+func TestValidateLoginForwardAuth(t *testing.T) {
+	a := newHashOnlyAgent(t)
+	a.SetPasswordHash("toby", Hash("local-pw"))
+
+	tests := []struct {
+		name       string
+		hook       ForwardAuthHandler
+		password   string
+		wantOK     bool
+		wantReason string
+	}{
+		{"no hook, local ok", nil, "local-pw", true, ""},
+		{"no hook, local wrong", nil, "bad", false, "Invalid username or password"},
+		{"hook undecided falls back", func(u, h string) ForwardAuthResult { return ForwardAuthResult{} }, "local-pw", true, ""},
+		{"hook accepts", func(u, h string) ForwardAuthResult {
+			if u == "toby" && h == Hash("remote-pw") {
+				return ForwardAuthResult{Decided: true, Accepted: true}
+			}
+			return ForwardAuthResult{Decided: true, Accepted: false, Reason: "denied"}
+		}, "remote-pw", true, ""},
+		{"hook rejects without fallback", func(u, h string) ForwardAuthResult {
+			return ForwardAuthResult{Decided: true, Accepted: false, Reason: "denied by origin"}
+		}, "local-pw", false, "denied by origin"},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			a.ForwardAuth = tc.hook
+			ok, reason := a.validateLogin("toby", tc.password)
+			if ok != tc.wantOK || reason != tc.wantReason {
+				t.Errorf("validateLogin = (%v, %q) want (%v, %q)", ok, reason, tc.wantOK, tc.wantReason)
+			}
+		})
+	}
+}
+
+func TestForwardAuthHookReceivesHashNotPassword(t *testing.T) {
+	a := newHashOnlyAgent(t)
+	var seen string
+	a.ForwardAuth = func(u, h string) ForwardAuthResult {
+		seen = h
+		return ForwardAuthResult{Decided: true, Accepted: true}
+	}
+	a.validateLogin("toby", "clear-text")
+	if seen == "clear-text" || seen != Hash("clear-text") {
+		t.Errorf("hook must receive the SHA-512 hash, got %q", seen)
+	}
+}

+ 47 - 2
src/mod/cluster/README.md

@@ -14,7 +14,8 @@ the end lists what exists today.
 |---|---|
 | `acn/` | **ArozOS Cluster Node protocol** – the node-to-node transport. Ed25519 node keys, signed HTTP requests with replay protection, direct / tunnel / relay routing, and the WebSocket reverse tunnel for NAT-only nodes. |
 | `capability/` | Portable detection of what a node offers (OS, arch, cores, RAM, tools such as ffmpeg/docker/nvidia, CPU feature flags) plus `Requirements` matching for the scheduler, and a cross-platform `DiskUsage`. |
-| `membership/` | The cluster agent: create / join / leave, replicated membership records, join tokens, heartbeats, health, node states, tunnel host selection, and the admin (System Settings) endpoints. |
+| `membership/` | The cluster agent: create / join / leave, replicated membership records, join tokens, heartbeats, health, node states, tunnel host selection, cluster-wide settings, and the admin (System Settings) endpoints. |
+| `identity/` | **ArozOS Identity** – forward authentication to the cluster's identity origin, replicated account directory as fallback, password write-back, and signed user assertions for cross-node requests. |
 | `wakeonlan/` | Wake-on-LAN packets for offline LAN neighbours. |
 
 Core wiring lives in [`src/cluster.go`](../../cluster.go); the ACN endpoints
@@ -114,6 +115,50 @@ learns its `LastSeen` and gossips it.
    has no URL it immediately tunnels to the issuer.
 3. The issuer pushes the new member list to everyone else.
 
+## Identity (AID)
+
+Package `identity/`. One member can be made the **identity origin** (the
+SSO owner) in System Settings › Cluster › Identity; the choice is a
+cluster-wide setting replicated by gossip (`ClusterInfo.IdentityOrigin`,
+last-writer-wins on `SettingsVersion`).
+
+On every other member the auth agent's `ForwardAuth` hook runs before the
+local password table:
+
+1. **Forward auth** – the member sends the username and the SHA-512 hash of
+   the typed password to the origin (`POST /cluster/acn/auth/verify`, signed).
+   The origin compares hashes in constant time and answers with the user's
+   group names. Never the clear-text password.
+2. **Mirror** – on success the member creates or updates the account locally
+   (hash + the groups that exist on that node, matched by name) and opens a
+   normal local session. Group settings stay node-specific, so create the
+   same permission groups on every node.
+3. **Fallback** – if the origin is *unreachable* the hook has no opinion and
+   the local table decides, which works because members pull the origin's
+   account directory (`GET /cluster/acn/auth/directory`) at boot, every 5
+   minutes and whenever the origin setting changes. If the origin is
+   reachable and says "wrong password", the login is rejected (no fallback).
+4. **Write-back** – password changes of mirrored accounts on a member are
+   forwarded to the origin (`POST /cluster/acn/auth/setpassword`) so the next
+   sync does not revert them.
+
+Rules: only accounts the member mirrored are ever touched; a pre-existing
+local account with the same name is left as is (listed as "local only").
+Accounts whose groups do not exist on the member are skipped and listed.
+The origin itself always authenticates locally.
+
+**Signed user assertions** (`identity.Assertion`) let node A act on node B
+for a logged-in user: `Issue(username)` produces
+`base64url(payload).base64url(Ed25519 signature)`, carried in the
+`X-Aroz-User` header, and `Verify` checks it against the issuer's published
+node key from the membership list, with a 5 minute lifetime. No node needs to
+contact the origin to trust a cross-node request.
+
+Admin API: `/system/cluster/identity/{status,origin,sync}`.
+
+Note: the directory carries password hashes, so put node URLs behind HTTPS
+(Cloudflare or your own certificates) in production.
+
 ## Admin API (`/system/cluster/*`, admin only)
 
 `status`, `create`, `join`, `leave`, `config`, `testurl`, `token/new`,
@@ -126,7 +171,7 @@ learns its `LastSeen` and gossips it.
 | Phase | Status |
 |---|---|
 | 1 Membership (keys, ACN, tunnel/relay, join/leave, heartbeat, capabilities, health, settings UI) | done |
-| 2 Identity (forward-auth to an origin node, replicated accounts as fallback, signed user assertions) | planned |
+| 2 Identity (forward-auth to an origin node, replicated accounts as fallback, signed user assertions) | done |
 | 3 Metadata store (leader lease + replicated log, file records, locations, checksums) | planned |
 | 4 Unified namespace (`cluster:/` file system abstraction) | planned |
 | 5 Replication (whole files, 4 MB chunked transfer, checksum verified) | planned |

+ 146 - 0
src/mod/cluster/identity/assertion.go

@@ -0,0 +1,146 @@
+package identity
+
+/*
+	ArozOS Cluster - signed user assertions
+
+	When node A performs an action on node B on behalf of a logged-in user,
+	it attaches an assertion: a short-lived statement "user U with groups G"
+	signed with A's node key. B verifies it with A's published public key
+	from the membership list, so no node ever has to contact the identity
+	origin to trust a cross-node request.
+
+	Encoding: base64url(JSON payload) "." base64url(Ed25519 signature)
+	The signature covers the literal payload bytes.
+*/
+
+import (
+	"crypto/ed25519"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"net/http"
+	"strings"
+	"time"
+)
+
+const (
+	// HeaderUser carries an assertion on node-to-node requests.
+	HeaderUser = "X-Aroz-User"
+	// DefaultAssertionTTL is how long an assertion stays valid.
+	DefaultAssertionTTL = 5 * time.Minute
+	assertionMaxSkew    = 5 * time.Minute
+)
+
+var (
+	ErrAssertionInvalid = errors.New("user assertion invalid")
+	ErrAssertionExpired = errors.New("user assertion expired")
+	ErrAssertionIssuer  = errors.New("user assertion issuer is not a cluster member")
+)
+
+// Assertion is a signed statement about a user made by a node.
+type Assertion struct {
+	User     string   `json:"u"`
+	Groups   []string `json:"g"`
+	Issuer   string   `json:"i"`
+	Cluster  string   `json:"c"`
+	IssuedAt int64    `json:"t"`
+	Expires  int64    `json:"e"`
+}
+
+// Issue creates an assertion for a user known on this node.
+func (i *Manager) Issue(username string, ttl time.Duration) (string, error) {
+	if !validUsername(username) {
+		return "", errors.New("invalid username")
+	}
+	cluster := i.m.Cluster()
+	if cluster == nil {
+		return "", errors.New("not in a cluster")
+	}
+	if ttl <= 0 {
+		ttl = DefaultAssertionTTL
+	}
+	groups, _ := i.acc.Groups(username)
+	if groups == nil {
+		groups = []string{}
+	}
+	now := time.Now()
+	payload, err := json.Marshal(Assertion{
+		User:     username,
+		Groups:   groups,
+		Issuer:   i.m.NodeID(),
+		Cluster:  cluster.ID,
+		IssuedAt: now.Unix(),
+		Expires:  now.Add(ttl).Unix(),
+	})
+	if err != nil {
+		return "", err
+	}
+	sig := i.m.Sign(payload)
+	return base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(sig), nil
+}
+
+// Verify checks an assertion issued by any current cluster member.
+func (i *Manager) Verify(token string) (*Assertion, error) {
+	return i.verifyAt(token, time.Now())
+}
+
+func (i *Manager) verifyAt(token string, now time.Time) (*Assertion, error) {
+	parts := strings.SplitN(strings.TrimSpace(token), ".", 2)
+	if len(parts) != 2 {
+		return nil, ErrAssertionInvalid
+	}
+	payload, err := base64.RawURLEncoding.DecodeString(parts[0])
+	if err != nil {
+		return nil, ErrAssertionInvalid
+	}
+	sig, err := base64.RawURLEncoding.DecodeString(parts[1])
+	if err != nil || len(sig) != ed25519.SignatureSize {
+		return nil, ErrAssertionInvalid
+	}
+	var a Assertion
+	if err := json.Unmarshal(payload, &a); err != nil || !validUsername(a.User) || a.Issuer == "" {
+		return nil, ErrAssertionInvalid
+	}
+	cluster := i.m.Cluster()
+	if cluster == nil || a.Cluster != cluster.ID {
+		return nil, ErrAssertionInvalid
+	}
+	peer, ok := i.m.ResolvePeer(a.Issuer)
+	if !ok && a.Issuer == i.m.NodeID() {
+		//Assertions we issued ourselves: verify with our own key
+		self, _ := i.m.ResolvePeer(i.m.NodeID())
+		peer, ok = self, self != nil
+	}
+	if !ok || peer == nil {
+		return nil, ErrAssertionIssuer
+	}
+	if !ed25519.Verify(peer.PublicKey, payload, sig) {
+		return nil, ErrAssertionInvalid
+	}
+	if now.Unix() > a.Expires || a.IssuedAt > now.Add(assertionMaxSkew).Unix() {
+		return nil, ErrAssertionExpired
+	}
+	if a.Groups == nil {
+		a.Groups = []string{}
+	}
+	return &a, nil
+}
+
+// Attach adds a fresh assertion for username to an outgoing request.
+func (i *Manager) Attach(req *http.Request, username string) error {
+	token, err := i.Issue(username, DefaultAssertionTTL)
+	if err != nil {
+		return err
+	}
+	req.Header.Set(HeaderUser, token)
+	return nil
+}
+
+// FromRequest verifies the assertion carried by an incoming node request.
+func (i *Manager) FromRequest(r *http.Request) (*Assertion, error) {
+	token := r.Header.Get(HeaderUser)
+	if token == "" {
+		return nil, ErrAssertionInvalid
+	}
+	return i.Verify(token)
+}

+ 141 - 0
src/mod/cluster/identity/handlers.go

@@ -0,0 +1,141 @@
+package identity
+
+/*
+	ArozOS Cluster - identity endpoints
+
+	Node-facing (signed, served only by the identity origin):
+		POST /cluster/acn/auth/verify        check a username + password hash
+		GET  /cluster/acn/auth/directory     full account directory
+		POST /cluster/acn/auth/setpassword   password change forwarded by a member
+
+	Admin-facing (/system/cluster/identity/*, mounted admin-only by the core):
+		status, origin, sync
+*/
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+
+	"imuslab.com/arozos/mod/cluster/acn"
+	"imuslab.com/arozos/mod/utils"
+)
+
+func (i *Manager) registerACNHandlers() {
+	srv := i.m.Server()
+	srv.HandleFunc(acn.BasePath+"/auth/verify", i.handleVerify)
+	srv.HandleFunc(acn.BasePath+"/auth/directory", i.handleDirectory)
+	srv.HandleFunc(acn.BasePath+"/auth/setpassword", i.handleSetPassword)
+}
+
+// requireOrigin rejects node requests when this node is not the identity origin.
+func (i *Manager) requireOrigin(w http.ResponseWriter) bool {
+	if _, self := i.Origin(); !self {
+		acn.WriteError(w, http.StatusConflict, "this node is not the identity origin of the cluster")
+		return false
+	}
+	return true
+}
+
+func (i *Manager) handleVerify(w http.ResponseWriter, r *http.Request, sender *acn.SignedIdentity, body []byte) {
+	if !i.requireOrigin(w) {
+		return
+	}
+	var req VerifyRequest
+	if err := json.Unmarshal(body, &req); err != nil || !validUsername(req.Username) || req.PasswordHash == "" {
+		acn.WriteJSON(w, VerifyResponse{OK: false, Reason: "Invalid username or password"})
+		return
+	}
+	stored, err := i.acc.PasswordHash(req.Username)
+	if err != nil || stored == "" || !constantTimeEqual(stored, req.PasswordHash) {
+		acn.WriteJSON(w, VerifyResponse{OK: false, Reason: "Invalid username or password"})
+		return
+	}
+	groups, _ := i.acc.Groups(req.Username)
+	if groups == nil {
+		groups = []string{}
+	}
+	acn.WriteJSON(w, VerifyResponse{OK: true, Groups: groups})
+}
+
+func (i *Manager) handleDirectory(w http.ResponseWriter, r *http.Request, sender *acn.SignedIdentity, body []byte) {
+	if !i.requireOrigin(w) {
+		return
+	}
+	acn.WriteJSON(w, i.buildDirectory())
+}
+
+func (i *Manager) handleSetPassword(w http.ResponseWriter, r *http.Request, sender *acn.SignedIdentity, body []byte) {
+	if !i.requireOrigin(w) {
+		return
+	}
+	var req SetPasswordRequest
+	if err := json.Unmarshal(body, &req); err != nil || !validUsername(req.Username) || req.PasswordHash == "" {
+		acn.WriteError(w, http.StatusBadRequest, "invalid request")
+		return
+	}
+	if !i.acc.UserExists(req.Username) {
+		acn.WriteError(w, http.StatusNotFound, "user not found on the identity origin")
+		return
+	}
+	if err := i.acc.SetPasswordHash(req.Username, req.PasswordHash); err != nil {
+		acn.WriteError(w, http.StatusInternalServerError, err.Error())
+		return
+	}
+	acn.WriteJSON(w, map[string]bool{"ok": true})
+}
+
+func constantTimeEqual(a string, b string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	var diff byte
+	for k := 0; k < len(a); k++ {
+		diff |= a[k] ^ b[k]
+	}
+	return diff == 0
+}
+
+/*
+	Admin handlers
+*/
+
+// HandleStatus returns the identity status.
+func (i *Manager) HandleStatus(w http.ResponseWriter, r *http.Request) {
+	js, _ := json.Marshal(i.Status())
+	utils.SendJSONResponse(w, string(js))
+}
+
+// HandleOrigin sets the identity origin: id=self, id=<node id>, or id= (disable).
+func (i *Manager) HandleOrigin(w http.ResponseWriter, r *http.Request) {
+	if r.Method != http.MethodPost {
+		utils.SendErrorResponse(w, "POST required")
+		return
+	}
+	id := strings.TrimSpace(r.PostFormValue("id"))
+	if id == "self" {
+		id = i.m.NodeID()
+	}
+	if err := i.m.SetIdentityOrigin(id); err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+	utils.SendOK(w)
+}
+
+// HandleSync pulls the origin directory immediately.
+func (i *Manager) HandleSync(w http.ResponseWriter, r *http.Request) {
+	if err := i.SyncNow(); err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+	js, _ := json.Marshal(i.Status())
+	utils.SendJSONResponse(w, string(js))
+}
+
+// RegisterAdminRoutes mounts the admin handlers on the given register function.
+func (i *Manager) RegisterAdminRoutes(register func(pattern string, handler func(http.ResponseWriter, *http.Request))) {
+	register("/system/cluster/identity/status", i.HandleStatus)
+	register("/system/cluster/identity/origin", i.HandleOrigin)
+	register("/system/cluster/identity/sync", i.HandleSync)
+}

+ 473 - 0
src/mod/cluster/identity/identity.go

@@ -0,0 +1,473 @@
+package identity
+
+/*
+	ArozOS Cluster - identity service (AID)
+
+	One member of the cluster can be elected the "identity origin" (SSO
+	owner). Every other member then:
+
+	  1. forwards login attempts to the origin (forward auth): the origin
+	     checks the SHA-512 password hash against its own account table and
+	     answers with the user's groups. The member mirrors the account
+	     locally (hash + groups that exist on the member) and opens a normal
+	     local session.
+	  2. keeps a replicated copy of the origin's account directory so users
+	     can still log in when the origin is unreachable (fallback).
+	  3. forwards password changes of replicated accounts back to the origin.
+
+	Accounts that exist only on a member (never replicated) are left alone,
+	so every node still works standalone. Group membership is mapped by group
+	NAME: only groups that exist locally are applied, because groups carry
+	node-specific storage and module settings.
+*/
+
+import (
+	"context"
+	"errors"
+	"net/http"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	"imuslab.com/arozos/mod/auth"
+	"imuslab.com/arozos/mod/cluster/acn"
+	"imuslab.com/arozos/mod/cluster/membership"
+	"imuslab.com/arozos/mod/database"
+	"imuslab.com/arozos/mod/info/logger"
+)
+
+const (
+	// DefaultSyncInterval is how often members pull the origin directory.
+	DefaultSyncInterval = 5 * time.Minute
+	forwardAuthTimeout  = 12 * time.Second
+	keyManagedPrefix    = "managed/"
+)
+
+// AccountStore is what the identity service needs from the host's account
+// system. The core adapts the auth agent and permission handler to it.
+type AccountStore interface {
+	UserExists(username string) bool
+	PasswordHash(username string) (string, error)
+	SetPasswordHash(username string, hash string) error
+	Groups(username string) ([]string, error)
+	SetGroups(username string, groups []string) error
+	ListUsers() []string
+	DeleteUser(username string) error
+	GroupExists(group string) bool
+}
+
+// Account is one replicated user entry.
+type Account struct {
+	Username     string   `json:"username"`
+	PasswordHash string   `json:"passwordHash"`
+	Groups       []string `json:"groups"`
+}
+
+// Directory is the origin's full account list.
+type Directory struct {
+	Origin string    `json:"origin"`
+	Users  []Account `json:"users"`
+	Time   int64     `json:"time"`
+}
+
+// Wire payloads
+type VerifyRequest struct {
+	Username     string `json:"username"`
+	PasswordHash string `json:"passwordHash"`
+}
+
+type VerifyResponse struct {
+	OK     bool     `json:"ok"`
+	Reason string   `json:"reason,omitempty"`
+	Groups []string `json:"groups,omitempty"`
+}
+
+type SetPasswordRequest struct {
+	Username     string `json:"username"`
+	PasswordHash string `json:"passwordHash"`
+}
+
+// Option configures the identity service.
+type Option struct {
+	Membership   *membership.Manager
+	Accounts     AccountStore
+	SyncInterval time.Duration
+}
+
+// Manager is the identity service of this node.
+type Manager struct {
+	m   *membership.Manager
+	acc AccountStore
+	db  *database.Database
+
+	mu           sync.Mutex
+	managed      map[string]bool //accounts on this node that mirror the origin
+	lastSync     int64
+	lastSyncErr  string
+	lastForward  int64
+	lastForwardE string
+	skipped      []string //directory users without a matching local group
+	conflicts    []string //directory users shadowed by a local-only account
+	interval     time.Duration
+
+	stop    chan struct{}
+	trigger chan struct{}
+	once    sync.Once
+}
+
+// New creates the identity service and registers its ACN endpoints.
+func New(opt Option) (*Manager, error) {
+	if opt.Membership == nil || opt.Accounts == nil {
+		return nil, errors.New("membership manager and account store are required")
+	}
+	if opt.SyncInterval <= 0 {
+		opt.SyncInterval = DefaultSyncInterval
+	}
+	i := &Manager{
+		m:        opt.Membership,
+		acc:      opt.Accounts,
+		db:       opt.Membership.DB(),
+		managed:  map[string]bool{},
+		interval: opt.SyncInterval,
+		stop:     make(chan struct{}),
+		trigger:  make(chan struct{}, 1),
+	}
+	i.db.NewTable(membership.TableIdentity)
+	i.loadManaged()
+	i.registerACNHandlers()
+	prev := i.m.OnClusterChange
+	i.m.OnClusterChange = func() {
+		if prev != nil {
+			prev()
+		}
+		i.TriggerSync()
+	}
+	go i.loop()
+	return i, nil
+}
+
+// Close stops the background sync.
+func (i *Manager) Close() {
+	i.once.Do(func() { close(i.stop) })
+}
+
+func (i *Manager) loadManaged() {
+	entries, err := i.db.ListTable(membership.TableIdentity)
+	if err != nil {
+		return
+	}
+	i.mu.Lock()
+	defer i.mu.Unlock()
+	for _, kv := range entries {
+		k := string(kv[0])
+		if strings.HasPrefix(k, keyManagedPrefix) {
+			i.managed[strings.TrimPrefix(k, keyManagedPrefix)] = true
+		}
+	}
+}
+
+func (i *Manager) markManaged(username string, origin string) {
+	i.mu.Lock()
+	i.managed[username] = true
+	i.mu.Unlock()
+	i.db.Write(membership.TableIdentity, keyManagedPrefix+username, origin)
+}
+
+func (i *Manager) unmarkManaged(username string) {
+	i.mu.Lock()
+	delete(i.managed, username)
+	i.mu.Unlock()
+	i.db.Delete(membership.TableIdentity, keyManagedPrefix+username)
+}
+
+// IsManaged reports whether an account on this node mirrors the origin.
+func (i *Manager) IsManaged(username string) bool {
+	i.mu.Lock()
+	defer i.mu.Unlock()
+	return i.managed[username]
+}
+
+// Origin returns the identity origin node and whether it is this node.
+func (i *Manager) Origin() (nodeID string, isSelf bool) {
+	origin := i.m.IdentityOrigin()
+	return origin, origin != "" && origin == i.m.NodeID()
+}
+
+// forwardingActive is true when logins should be checked on another node.
+func (i *Manager) forwardingActive() (string, bool) {
+	origin, self := i.Origin()
+	if origin == "" || self || !i.m.InCluster() {
+		return "", false
+	}
+	return origin, true
+}
+
+// localGroups keeps only the groups that exist on this node.
+func (i *Manager) localGroups(groups []string) []string {
+	out := []string{}
+	for _, g := range groups {
+		if g != "" && i.acc.GroupExists(g) {
+			out = append(out, g)
+		}
+	}
+	return out
+}
+
+// mirrorAccount creates or updates the local copy of an origin account.
+func (i *Manager) mirrorAccount(origin string, username string, hash string, groups []string) error {
+	if err := i.acc.SetPasswordHash(username, hash); err != nil {
+		return err
+	}
+	if err := i.acc.SetGroups(username, groups); err != nil {
+		return err
+	}
+	i.markManaged(username, origin)
+	return nil
+}
+
+/*
+	Forward authentication
+*/
+
+// ForwardAuth is installed as the auth agent's ForwardAuth hook.
+func (i *Manager) ForwardAuth(username string, passwordHash string) auth.ForwardAuthResult {
+	origin, active := i.forwardingActive()
+	if !active {
+		return auth.ForwardAuthResult{}
+	}
+	if !validUsername(username) {
+		return auth.ForwardAuthResult{Decided: true, Accepted: false, Reason: "Invalid username or password"}
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), forwardAuthTimeout)
+	defer cancel()
+	var resp VerifyResponse
+	err := i.m.Transport().DoJSON(ctx, origin, http.MethodPost, acn.BasePath+"/auth/verify", VerifyRequest{Username: username, PasswordHash: passwordHash}, &resp)
+	i.mu.Lock()
+	i.lastForward = time.Now().Unix()
+	if err != nil {
+		i.lastForwardE = err.Error()
+	} else {
+		i.lastForwardE = ""
+	}
+	i.mu.Unlock()
+	if err != nil {
+		//Origin unreachable: let the local (replicated) table decide
+		logger.PrintAndLog("Cluster", "Identity origin unreachable, using local accounts: "+err.Error(), nil)
+		return auth.ForwardAuthResult{}
+	}
+	if !resp.OK {
+		reason := resp.Reason
+		if reason == "" {
+			reason = "Invalid username or password"
+		}
+		return auth.ForwardAuthResult{Decided: true, Accepted: false, Reason: reason}
+	}
+
+	//Mirror the account locally so the session and permissions resolve
+	if i.acc.UserExists(username) && !i.IsManaged(username) {
+		//A local-only account with the same name: keep it untouched but the
+		//origin decided the password, so just let the user in with local groups
+		return auth.ForwardAuthResult{Decided: true, Accepted: true}
+	}
+	groups := i.localGroups(resp.Groups)
+	if len(groups) == 0 {
+		return auth.ForwardAuthResult{Decided: true, Accepted: false, Reason: "None of your permission groups exist on this node. Ask an administrator to create a matching group."}
+	}
+	if err := i.mirrorAccount(origin, username, passwordHash, groups); err != nil {
+		return auth.ForwardAuthResult{Decided: true, Accepted: false, Reason: "Unable to create your account on this node: " + err.Error()}
+	}
+	return auth.ForwardAuthResult{Decided: true, Accepted: true}
+}
+
+// NotifyPasswordChanged forwards a password change of a replicated account
+// to the origin so it does not get reverted by the next sync.
+func (i *Manager) NotifyPasswordChanged(username string, passwordHash string) {
+	origin, active := i.forwardingActive()
+	if !active || !i.IsManaged(username) {
+		return
+	}
+	go func() {
+		ctx, cancel := context.WithTimeout(context.Background(), forwardAuthTimeout)
+		defer cancel()
+		err := i.m.Transport().DoJSON(ctx, origin, http.MethodPost, acn.BasePath+"/auth/setpassword", SetPasswordRequest{Username: username, PasswordHash: passwordHash}, nil)
+		if err != nil {
+			logger.PrintAndLog("Cluster", "Unable to forward password change of "+username+" to the identity origin: "+err.Error(), nil)
+		}
+	}()
+}
+
+/*
+	Directory replication
+*/
+
+// buildDirectory lists the accounts of this node (served when it is the origin).
+func (i *Manager) buildDirectory() Directory {
+	dir := Directory{Origin: i.m.NodeID(), Users: []Account{}, Time: time.Now().Unix()}
+	users := i.acc.ListUsers()
+	sort.Strings(users)
+	for _, u := range users {
+		hash, err := i.acc.PasswordHash(u)
+		if err != nil || hash == "" {
+			continue
+		}
+		groups, _ := i.acc.Groups(u)
+		if groups == nil {
+			groups = []string{}
+		}
+		dir.Users = append(dir.Users, Account{Username: u, PasswordHash: hash, Groups: groups})
+	}
+	return dir
+}
+
+// applyDirectory mirrors the origin directory onto this node.
+func (i *Manager) applyDirectory(origin string, dir Directory) {
+	seen := map[string]bool{}
+	skipped := []string{}
+	conflicts := []string{}
+	for _, a := range dir.Users {
+		if !validUsername(a.Username) || a.PasswordHash == "" {
+			continue
+		}
+		if i.acc.UserExists(a.Username) && !i.IsManaged(a.Username) {
+			conflicts = append(conflicts, a.Username)
+			continue
+		}
+		groups := i.localGroups(a.Groups)
+		if len(groups) == 0 {
+			skipped = append(skipped, a.Username)
+			continue
+		}
+		seen[a.Username] = true
+		if err := i.mirrorAccount(origin, a.Username, a.PasswordHash, groups); err != nil {
+			logger.PrintAndLog("Cluster", "Unable to mirror account "+a.Username+": "+err.Error(), nil)
+		}
+	}
+
+	//Accounts we mirrored earlier that the origin no longer has
+	i.mu.Lock()
+	stale := []string{}
+	for u := range i.managed {
+		if !seen[u] {
+			stale = append(stale, u)
+		}
+	}
+	i.skipped = skipped
+	i.conflicts = conflicts
+	i.mu.Unlock()
+	for _, u := range stale {
+		if err := i.acc.DeleteUser(u); err == nil {
+			logger.PrintAndLog("Cluster", "Removed replicated account "+u+" that no longer exists on the identity origin", nil)
+		}
+		i.unmarkManaged(u)
+	}
+}
+
+// SyncNow pulls the origin directory once. It is a no-op on the origin
+// itself or when forward authentication is disabled.
+func (i *Manager) SyncNow() error {
+	origin, active := i.forwardingActive()
+	if !active {
+		return nil
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+	var dir Directory
+	err := i.m.Transport().DoJSON(ctx, origin, http.MethodGet, acn.BasePath+"/auth/directory", nil, &dir)
+	i.mu.Lock()
+	i.lastSync = time.Now().Unix()
+	if err != nil {
+		i.lastSyncErr = err.Error()
+	} else {
+		i.lastSyncErr = ""
+	}
+	i.mu.Unlock()
+	if err != nil {
+		return err
+	}
+	if dir.Origin != origin {
+		return errors.New("directory came from an unexpected node")
+	}
+	i.applyDirectory(origin, dir)
+	return nil
+}
+
+// TriggerSync asks the background loop to sync soon.
+func (i *Manager) TriggerSync() {
+	select {
+	case i.trigger <- struct{}{}:
+	default:
+	}
+}
+
+func (i *Manager) loop() {
+	ticker := time.NewTicker(i.interval)
+	defer ticker.Stop()
+	//Initial sync shortly after boot so members are warm before first login
+	timer := time.NewTimer(3 * time.Second)
+	defer timer.Stop()
+	for {
+		select {
+		case <-i.stop:
+			return
+		case <-timer.C:
+			i.SyncNow()
+		case <-ticker.C:
+			i.SyncNow()
+		case <-i.trigger:
+			i.SyncNow()
+		}
+	}
+}
+
+/*
+	Status
+*/
+
+// Status is the identity picture for the settings UI.
+type Status struct {
+	Enabled          bool     `json:"enabled"`
+	Origin           string   `json:"origin"`
+	OriginName       string   `json:"originName"`
+	IsOrigin         bool     `json:"isOrigin"`
+	ManagedAccounts  int      `json:"managedAccounts"`
+	LocalAccounts    int      `json:"localAccounts"`
+	Skipped          []string `json:"skipped"`
+	Conflicts        []string `json:"conflicts"`
+	LastSync         int64    `json:"lastSync"`
+	LastSyncError    string   `json:"lastSyncError"`
+	LastForward      int64    `json:"lastForward"`
+	LastForwardError string   `json:"lastForwardError"`
+}
+
+// Status builds the current identity status.
+func (i *Manager) Status() Status {
+	origin, self := i.Origin()
+	i.mu.Lock()
+	defer i.mu.Unlock()
+	st := Status{
+		Enabled:          origin != "",
+		Origin:           origin,
+		IsOrigin:         self,
+		ManagedAccounts:  len(i.managed),
+		LocalAccounts:    len(i.acc.ListUsers()),
+		Skipped:          append([]string{}, i.skipped...),
+		Conflicts:        append([]string{}, i.conflicts...),
+		LastSync:         i.lastSync,
+		LastSyncError:    i.lastSyncErr,
+		LastForward:      i.lastForward,
+		LastForwardError: i.lastForwardE,
+	}
+	if origin != "" {
+		st.OriginName = i.m.NodeName(origin)
+	}
+	return st
+}
+
+func validUsername(u string) bool {
+	if u == "" || len(u) > 128 {
+		return false
+	}
+	return !strings.ContainsAny(u, "/\\\x00")
+}

+ 464 - 0
src/mod/cluster/identity/identity_test.go

@@ -0,0 +1,464 @@
+package identity
+
+import (
+	"net/http/httptest"
+	"path/filepath"
+	"sort"
+	"sync"
+	"testing"
+	"time"
+
+	"imuslab.com/arozos/mod/auth"
+	"imuslab.com/arozos/mod/cluster/membership"
+)
+
+/*
+	Fake account store
+*/
+
+type fakeAccounts struct {
+	mu     sync.Mutex
+	hashes map[string]string
+	groups map[string][]string
+	known  map[string]bool //groups that exist on this node
+}
+
+func newFakeAccounts(groups ...string) *fakeAccounts {
+	f := &fakeAccounts{hashes: map[string]string{}, groups: map[string][]string{}, known: map[string]bool{}}
+	for _, g := range groups {
+		f.known[g] = true
+	}
+	return f
+}
+
+func (f *fakeAccounts) add(user, password string, groups ...string) {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	f.hashes[user] = auth.Hash(password)
+	f.groups[user] = groups
+}
+
+func (f *fakeAccounts) UserExists(u string) bool {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return f.hashes[u] != ""
+}
+func (f *fakeAccounts) PasswordHash(u string) (string, error) {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return f.hashes[u], nil
+}
+func (f *fakeAccounts) SetPasswordHash(u, h string) error {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	f.hashes[u] = h
+	return nil
+}
+func (f *fakeAccounts) Groups(u string) ([]string, error) {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return append([]string{}, f.groups[u]...), nil
+}
+func (f *fakeAccounts) SetGroups(u string, g []string) error {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	f.groups[u] = append([]string{}, g...)
+	return nil
+}
+func (f *fakeAccounts) ListUsers() []string {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	out := []string{}
+	for u := range f.hashes {
+		out = append(out, u)
+	}
+	sort.Strings(out)
+	return out
+}
+func (f *fakeAccounts) DeleteUser(u string) error {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	delete(f.hashes, u)
+	delete(f.groups, u)
+	return nil
+}
+func (f *fakeAccounts) GroupExists(g string) bool {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+	return f.known[g]
+}
+
+/*
+	Node helper: membership manager + identity service + HTTP server
+*/
+
+type testNode struct {
+	m   *membership.Manager
+	id  *Manager
+	acc *fakeAccounts
+	srv *httptest.Server
+}
+
+func newTestNode(t *testing.T, nodeID string, reachable bool, groups ...string) *testNode {
+	t.Helper()
+	dir := t.TempDir()
+	m, err := membership.NewManager(membership.Option{
+		NodeID:      nodeID,
+		DBFile:      filepath.Join(dir, "cluster.db"),
+		KeyFile:     filepath.Join(dir, "node.key"),
+		Version:     "test",
+		DefaultName: "Node " + nodeID,
+	})
+	if err != nil {
+		t.Fatalf("NewManager(%s): %v", nodeID, err)
+	}
+	acc := newFakeAccounts(groups...)
+	id, err := New(Option{Membership: m, Accounts: acc, SyncInterval: time.Hour})
+	if err != nil {
+		t.Fatalf("identity.New(%s): %v", nodeID, err)
+	}
+	srv := httptest.NewServer(m.ACNHandler())
+	if reachable {
+		cfg := m.Config()
+		cfg.AdvertiseURL = srv.URL
+		if err := m.UpdateConfig(cfg); err != nil {
+			t.Fatalf("UpdateConfig: %v", err)
+		}
+	}
+	t.Cleanup(func() {
+		id.Close()
+		m.Close()
+		srv.Close()
+	})
+	return &testNode{m: m, id: id, acc: acc, srv: srv}
+}
+
+func waitFor(t *testing.T, what string, timeout time.Duration, cond func() bool) {
+	t.Helper()
+	deadline := time.Now().Add(timeout)
+	for !cond() {
+		if time.Now().After(deadline) {
+			t.Fatalf("timed out waiting for %s", what)
+		}
+		time.Sleep(25 * time.Millisecond)
+	}
+}
+
+// twoNodeCluster returns an origin (a) and a member (b) already joined.
+func twoNodeCluster(t *testing.T) (*testNode, *testNode) {
+	t.Helper()
+	a := newTestNode(t, "node-a", true, "administrator", "default")
+	b := newTestNode(t, "node-b", true, "administrator", "default")
+	if _, err := a.m.CreateCluster("Home"); err != nil {
+		t.Fatalf("CreateCluster: %v", err)
+	}
+	token, _, _ := a.m.NewJoinToken(time.Hour)
+	if _, err := b.m.JoinCluster(token); err != nil {
+		t.Fatalf("JoinCluster: %v", err)
+	}
+	waitFor(t, "b to see a", 5*time.Second, func() bool {
+		for _, n := range b.m.NodeViews() {
+			if n.ID == "node-a" && n.State == membership.StateOnline {
+				return true
+			}
+		}
+		return false
+	})
+	return a, b
+}
+
+/*
+	Tests
+*/
+
+func TestOriginSettingReplicates(t *testing.T) {
+	a, b := twoNodeCluster(t)
+	if _, self := b.id.Origin(); self {
+		t.Fatalf("b must not be origin by default")
+	}
+	if err := a.m.SetIdentityOrigin("ghost"); err != membership.ErrNodeNotFound {
+		t.Errorf("unknown origin accepted: %v", err)
+	}
+	if err := a.m.SetIdentityOrigin("node-a"); err != nil {
+		t.Fatalf("SetIdentityOrigin: %v", err)
+	}
+	waitFor(t, "b to learn the origin", 5*time.Second, func() bool { return b.m.IdentityOrigin() == "node-a" })
+	if origin, self := a.id.Origin(); origin != "node-a" || !self {
+		t.Errorf("a should be origin: %s %v", origin, self)
+	}
+	st := b.id.Status()
+	if !st.Enabled || st.IsOrigin || st.OriginName != "Node node-a" {
+		t.Errorf("b status wrong: %+v", st)
+	}
+
+	//Disable again from the member side and check it flows back
+	if err := b.m.SetIdentityOrigin(""); err != nil {
+		t.Fatalf("clear origin: %v", err)
+	}
+	waitFor(t, "a to learn the origin was cleared", 5*time.Second, func() bool { return a.m.IdentityOrigin() == "" })
+}
+
+func TestForwardAuth(t *testing.T) {
+	a, b := twoNodeCluster(t)
+	a.acc.add("toby", "secret", "administrator", "photographers")
+	a.acc.add("guest", "pw", "visitors") //group unknown on b
+
+	//No origin: b has no opinion
+	if res := b.id.ForwardAuth("toby", auth.Hash("secret")); res.Decided {
+		t.Errorf("without origin ForwardAuth must not decide")
+	}
+
+	a.m.SetIdentityOrigin("node-a")
+	waitFor(t, "origin replicated", 5*time.Second, func() bool { return b.m.IdentityOrigin() == "node-a" })
+
+	//Origin itself never forwards
+	if res := a.id.ForwardAuth("toby", auth.Hash("secret")); res.Decided {
+		t.Errorf("origin must decide locally")
+	}
+
+	//Valid login on the member mirrors the account with the groups b knows
+	res := b.id.ForwardAuth("toby", auth.Hash("secret"))
+	if !res.Decided || !res.Accepted {
+		t.Fatalf("valid login rejected: %+v", res)
+	}
+	if !b.acc.UserExists("toby") || !b.id.IsManaged("toby") {
+		t.Errorf("account not mirrored on b")
+	}
+	groups, _ := b.acc.Groups("toby")
+	if len(groups) != 1 || groups[0] != "administrator" {
+		t.Errorf("groups not filtered to local ones: %v", groups)
+	}
+
+	//Wrong password: decided and rejected, no fallback
+	res = b.id.ForwardAuth("toby", auth.Hash("nope"))
+	if !res.Decided || res.Accepted {
+		t.Errorf("wrong password accepted: %+v", res)
+	}
+
+	//Unknown user
+	res = b.id.ForwardAuth("nobody", auth.Hash("x"))
+	if !res.Decided || res.Accepted {
+		t.Errorf("unknown user accepted: %+v", res)
+	}
+
+	//User whose groups do not exist on b
+	res = b.id.ForwardAuth("guest", auth.Hash("pw"))
+	if !res.Decided || res.Accepted || res.Reason == "" {
+		t.Errorf("user without local groups should be rejected with a reason: %+v", res)
+	}
+
+	//Local-only account on b keeps its own groups but the origin decides the password
+	b.acc.add("local", "localpw", "default")
+	a.acc.add("local", "originpw", "administrator")
+	res = b.id.ForwardAuth("local", auth.Hash("originpw"))
+	if !res.Decided || !res.Accepted {
+		t.Errorf("origin password for shadowed account should be accepted: %+v", res)
+	}
+	if b.id.IsManaged("local") {
+		t.Errorf("local-only account must not become managed")
+	}
+	g, _ := b.acc.Groups("local")
+	if len(g) != 1 || g[0] != "default" {
+		t.Errorf("local-only account groups were overwritten: %v", g)
+	}
+}
+
+func TestForwardAuthFallsBackWhenOriginDown(t *testing.T) {
+	a, b := twoNodeCluster(t)
+	a.acc.add("toby", "secret", "administrator")
+	a.m.SetIdentityOrigin("node-a")
+	waitFor(t, "origin replicated", 5*time.Second, func() bool { return b.m.IdentityOrigin() == "node-a" })
+	if err := b.id.SyncNow(); err != nil {
+		t.Fatalf("SyncNow: %v", err)
+	}
+	if !b.acc.UserExists("toby") {
+		t.Fatalf("account not replicated")
+	}
+
+	a.srv.Close() //origin goes away
+	res := b.id.ForwardAuth("toby", auth.Hash("secret"))
+	if res.Decided {
+		t.Errorf("unreachable origin must fall back to local accounts, got %+v", res)
+	}
+	//and the local replicated hash still validates
+	h, _ := b.acc.PasswordHash("toby")
+	if h != auth.Hash("secret") {
+		t.Errorf("replicated hash wrong")
+	}
+	if st := b.id.Status(); st.LastForwardError == "" {
+		t.Errorf("status should report the forward error")
+	}
+}
+
+func TestDirectorySync(t *testing.T) {
+	a, b := twoNodeCluster(t)
+	a.acc.add("toby", "s1", "administrator")
+	a.acc.add("alice", "s2", "default", "unknown-on-b")
+	a.acc.add("bob", "s3", "unknown-on-b")
+	b.acc.add("alice", "mine", "default") //local-only account shadowing an origin one
+
+	//Directory is refused unless the node is the origin
+	if err := b.id.SyncNow(); err != nil {
+		t.Errorf("sync without origin should be a no-op, got %v", err)
+	}
+	a.m.SetIdentityOrigin("node-a")
+	waitFor(t, "origin replicated", 5*time.Second, func() bool { return b.m.IdentityOrigin() == "node-a" })
+
+	if err := b.id.SyncNow(); err != nil {
+		t.Fatalf("SyncNow: %v", err)
+	}
+	if !b.acc.UserExists("toby") || !b.id.IsManaged("toby") {
+		t.Errorf("toby not replicated")
+	}
+	if h, _ := b.acc.PasswordHash("alice"); h != auth.Hash("mine") {
+		t.Errorf("local-only alice was overwritten")
+	}
+	if b.acc.UserExists("bob") {
+		t.Errorf("bob has no local group and must be skipped")
+	}
+	st := b.id.Status()
+	if len(st.Skipped) != 1 || st.Skipped[0] != "bob" || len(st.Conflicts) != 1 || st.Conflicts[0] != "alice" {
+		t.Errorf("status skipped/conflicts wrong: %+v", st)
+	}
+	if st.ManagedAccounts != 1 || st.LastSync == 0 {
+		t.Errorf("status counts wrong: %+v", st)
+	}
+
+	//Password change on the origin propagates, removal on the origin removes the mirror
+	a.acc.add("toby", "s1-new", "administrator")
+	a.acc.add("carol", "s4", "default")
+	if err := b.id.SyncNow(); err != nil {
+		t.Fatalf("second sync: %v", err)
+	}
+	if h, _ := b.acc.PasswordHash("toby"); h != auth.Hash("s1-new") {
+		t.Errorf("password change not replicated")
+	}
+	if !b.acc.UserExists("carol") {
+		t.Errorf("new account not replicated")
+	}
+	a.acc.DeleteUser("carol")
+	if err := b.id.SyncNow(); err != nil {
+		t.Fatalf("third sync: %v", err)
+	}
+	if b.acc.UserExists("carol") || b.id.IsManaged("carol") {
+		t.Errorf("removed origin account still present on member")
+	}
+
+	//Managed markers survive a restart of the identity service (rebuild the
+	//in-memory set from the database without re-registering endpoints)
+	id2 := &Manager{m: b.m, acc: b.acc, db: b.m.DB(), managed: map[string]bool{}}
+	id2.loadManaged()
+	if !id2.IsManaged("toby") {
+		t.Errorf("managed marker not persisted")
+	}
+}
+
+func TestPasswordChangeForwardedToOrigin(t *testing.T) {
+	a, b := twoNodeCluster(t)
+	a.acc.add("toby", "old", "administrator")
+	a.m.SetIdentityOrigin("node-a")
+	waitFor(t, "origin replicated", 5*time.Second, func() bool { return b.m.IdentityOrigin() == "node-a" })
+	if err := b.id.SyncNow(); err != nil {
+		t.Fatalf("SyncNow: %v", err)
+	}
+
+	newHash := auth.Hash("new")
+	b.acc.SetPasswordHash("toby", newHash)
+	b.id.NotifyPasswordChanged("toby", newHash)
+	waitFor(t, "origin to receive the new hash", 5*time.Second, func() bool {
+		h, _ := a.acc.PasswordHash("toby")
+		return h == newHash
+	})
+
+	//Local-only accounts are never forwarded
+	b.acc.add("localonly", "x", "default")
+	b.id.NotifyPasswordChanged("localonly", auth.Hash("y"))
+	time.Sleep(200 * time.Millisecond)
+	if a.acc.UserExists("localonly") {
+		t.Errorf("local-only account leaked to origin")
+	}
+}
+
+func TestOriginEndpointsRefuseOnNonOrigin(t *testing.T) {
+	a, b := twoNodeCluster(t)
+	a.m.SetIdentityOrigin("node-a")
+	waitFor(t, "origin replicated", 5*time.Second, func() bool { return b.m.IdentityOrigin() == "node-a" })
+	//Ask b (not the origin) for a directory: must be refused
+	var dir Directory
+	err := a.m.Transport().DoJSON(t.Context(), "node-b", "GET", "/cluster/acn/auth/directory", nil, &dir)
+	if err == nil {
+		t.Errorf("non-origin served a directory")
+	}
+}
+
+func TestAssertions(t *testing.T) {
+	a, b := twoNodeCluster(t)
+	a.acc.add("toby", "s", "administrator", "photographers")
+
+	if _, err := a.id.Issue("bad/name", time.Minute); err == nil {
+		t.Errorf("invalid username accepted")
+	}
+	token, err := a.id.Issue("toby", time.Minute)
+	if err != nil {
+		t.Fatalf("Issue: %v", err)
+	}
+
+	//b verifies with a's published key
+	as, err := b.id.Verify(token)
+	if err != nil {
+		t.Fatalf("Verify on b: %v", err)
+	}
+	if as.User != "toby" || as.Issuer != "node-a" || len(as.Groups) != 2 {
+		t.Errorf("assertion content wrong: %+v", as)
+	}
+	//a verifies its own
+	if _, err := a.id.Verify(token); err != nil {
+		t.Errorf("self verify: %v", err)
+	}
+
+	//Tampered payload
+	if _, err := b.id.Verify("eyJ1IjoieCJ9." + token[len(token)-86:]); err == nil {
+		t.Errorf("tampered assertion accepted")
+	}
+	if _, err := b.id.Verify("garbage"); err != ErrAssertionInvalid {
+		t.Errorf("garbage should be invalid, got %v", err)
+	}
+
+	//Expired
+	if _, err := b.id.verifyAt(token, time.Now().Add(2*time.Minute)); err != ErrAssertionExpired {
+		t.Errorf("expired assertion should fail, got %v", err)
+	}
+
+	//Unknown issuer: a node outside the cluster
+	stranger := newTestNode(t, "node-x", true, "default")
+	stranger.m.CreateCluster("Other")
+	stranger.acc.add("toby", "s", "default")
+	foreign, err := stranger.id.Issue("toby", time.Minute)
+	if err != nil {
+		t.Fatalf("foreign issue: %v", err)
+	}
+	if _, err := b.id.Verify(foreign); err == nil {
+		t.Errorf("assertion from another cluster accepted")
+	}
+
+	//Header round trip
+	req := httptest.NewRequest("POST", "/cluster/acn/x", nil)
+	if err := a.id.Attach(req, "toby"); err != nil {
+		t.Fatalf("Attach: %v", err)
+	}
+	if got, err := b.id.FromRequest(req); err != nil || got.User != "toby" {
+		t.Errorf("FromRequest: %v %+v", err, got)
+	}
+	if _, err := b.id.FromRequest(httptest.NewRequest("GET", "/", nil)); err == nil {
+		t.Errorf("missing header accepted")
+	}
+}
+
+func TestValidUsername(t *testing.T) {
+	tests := map[string]bool{"toby": true, "": false, "a/b": false, "a\\b": false, "x\x00": false}
+	for u, want := range tests {
+		if got := validUsername(u); got != want {
+			t.Errorf("validUsername(%q)=%v want %v", u, got, want)
+		}
+	}
+}

+ 6 - 5
src/mod/cluster/membership/handlers_acn.go

@@ -116,7 +116,7 @@ func (m *Manager) handleJoin(w http.ResponseWriter, r *http.Request) {
 			others = append(others, id)
 		}
 	}
-	go m.broadcast(others, acn.BasePath+"/members/sync", SyncRequest{Nodes: m.snapshotRecords()}, 15*time.Second)
+	go m.broadcast(others, acn.BasePath+"/members/sync", m.syncPayload(), 15*time.Second)
 }
 
 func (m *Manager) handleHeartbeat(w http.ResponseWriter, r *http.Request, sender *acn.SignedIdentity, body []byte) {
@@ -126,14 +126,15 @@ func (m *Manager) handleHeartbeat(w http.ResponseWriter, r *http.Request, sender
 		return
 	}
 	req.Node.LastSeen = time.Now().Unix()
-	m.mergeNodes([]NodeRecord{req.Node})
+	m.mergeGossip(req.Cluster, []NodeRecord{req.Node})
 	m.markSeen(sender.NodeID)
-	acn.WriteJSON(w, HeartbeatResponse{Nodes: m.snapshotRecords(), Time: time.Now().Unix()})
+	payload := m.syncPayload()
+	acn.WriteJSON(w, HeartbeatResponse{Nodes: payload.Nodes, Cluster: payload.Cluster, Time: time.Now().Unix()})
 }
 
 func (m *Manager) handleMembers(w http.ResponseWriter, r *http.Request, sender *acn.SignedIdentity, body []byte) {
 	m.markSeen(sender.NodeID)
-	acn.WriteJSON(w, SyncRequest{Nodes: m.snapshotRecords()})
+	acn.WriteJSON(w, m.syncPayload())
 }
 
 func (m *Manager) handleSync(w http.ResponseWriter, r *http.Request, sender *acn.SignedIdentity, body []byte) {
@@ -143,7 +144,7 @@ func (m *Manager) handleSync(w http.ResponseWriter, r *http.Request, sender *acn
 		return
 	}
 	m.markSeen(sender.NodeID)
-	m.mergeNodes(req.Nodes)
+	m.mergeGossip(req.Cluster, req.Nodes)
 	acn.WriteJSON(w, map[string]bool{"ok": true})
 }
 

+ 121 - 6
src/mod/cluster/membership/manager.go

@@ -16,6 +16,7 @@ package membership
 
 import (
 	"context"
+	"crypto/ed25519"
 	"encoding/json"
 	"errors"
 	"net/http"
@@ -27,6 +28,7 @@ import (
 	uuid "github.com/satori/go.uuid"
 	"imuslab.com/arozos/mod/cluster/acn"
 	"imuslab.com/arozos/mod/cluster/capability"
+	"imuslab.com/arozos/mod/database"
 	"imuslab.com/arozos/mod/info/logger"
 )
 
@@ -66,6 +68,10 @@ type Manager struct {
 	loopWG    sync.WaitGroup
 	reachable map[string]bool //last known reachability per peer, for log de-duplication
 	started   time.Time
+
+	//OnClusterChange fires (outside the lock) whenever the replicated
+	//cluster-wide settings change, locally or through gossip.
+	OnClusterChange func()
 }
 
 var (
@@ -171,6 +177,104 @@ func (m *Manager) NodeID() string {
 	return m.opt.NodeID
 }
 
+// Sign signs an arbitrary message with this node's key so higher layers can
+// issue verifiable statements (e.g. user assertions).
+func (m *Manager) Sign(message []byte) []byte {
+	return ed25519.Sign(m.key.Private, message)
+}
+
+// DB exposes the cluster database so sibling cluster services can keep their
+// cluster-scoped state in it (use the TableIdentity table, wiped on leave).
+func (m *Manager) DB() *database.Database {
+	return m.store.db
+}
+
+// NodeName returns the display name of a member, or the ID when unknown.
+func (m *Manager) NodeName(nodeID string) string {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	if rec, ok := m.nodes[nodeID]; ok && rec.Name != "" {
+		return rec.Name
+	}
+	return nodeID
+}
+
+// IdentityOrigin returns the node that verifies logins for the cluster,
+// empty when unset or when not in a cluster.
+func (m *Manager) IdentityOrigin() string {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	if m.cluster == nil {
+		return ""
+	}
+	return m.cluster.IdentityOrigin
+}
+
+// SetIdentityOrigin makes nodeID the login authority of the cluster (empty
+// disables forward authentication) and replicates the setting.
+func (m *Manager) SetIdentityOrigin(nodeID string) error {
+	m.mu.Lock()
+	if m.cluster == nil {
+		m.mu.Unlock()
+		return ErrNotInCluster
+	}
+	if nodeID != "" {
+		rec, ok := m.nodes[nodeID]
+		if !ok || rec.Removed {
+			m.mu.Unlock()
+			return ErrNodeNotFound
+		}
+	}
+	m.cluster.IdentityOrigin = nodeID
+	m.cluster.SettingsVersion = nextVersion(m.cluster.SettingsVersion)
+	if err := m.store.saveCluster(m.cluster); err != nil {
+		m.mu.Unlock()
+		return err
+	}
+	peers := m.peerIDsLocked()
+	cb := m.OnClusterChange
+	m.mu.Unlock()
+
+	if cb != nil {
+		cb()
+	}
+	go m.broadcast(peers, acn.BasePath+"/members/sync", m.syncPayload(), 15*time.Second)
+	return nil
+}
+
+// mergeClusterLocked adopts newer replicated cluster settings.
+func (m *Manager) mergeClusterLocked(in *ClusterInfo) bool {
+	if in == nil || m.cluster == nil || in.ID != m.cluster.ID {
+		return false
+	}
+	if in.SettingsVersion <= m.cluster.SettingsVersion {
+		return false
+	}
+	m.cluster.IdentityOrigin = in.IdentityOrigin
+	m.cluster.SettingsVersion = in.SettingsVersion
+	if in.Name != "" {
+		m.cluster.Name = in.Name
+	}
+	m.store.saveCluster(m.cluster)
+	return true
+}
+
+// clusterCopyLocked returns a copy of the cluster info for gossip payloads.
+func (m *Manager) clusterCopyLocked() *ClusterInfo {
+	if m.cluster == nil {
+		return nil
+	}
+	c := *m.cluster
+	return &c
+}
+
+// syncPayload builds the full gossip payload (settings plus every record).
+func (m *Manager) syncPayload() SyncRequest {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	return SyncRequest{Cluster: m.clusterCopyLocked(), Nodes: m.allRecordsLocked()}
+}
+
 // InCluster reports whether this node is currently a cluster member.
 func (m *Manager) InCluster() bool {
 	m.mu.RLock()
@@ -351,11 +455,17 @@ func (m *Manager) mergeRecordLocked(in NodeRecord) (changed bool, evicted bool)
 
 // mergeNodes applies a batch of records, persists changes and handles eviction.
 func (m *Manager) mergeNodes(records []NodeRecord) {
+	m.mergeGossip(nil, records)
+}
+
+// mergeGossip applies replicated cluster settings and a batch of records.
+func (m *Manager) mergeGossip(cluster *ClusterInfo, records []NodeRecord) {
 	m.mu.Lock()
 	if m.cluster == nil {
 		m.mu.Unlock()
 		return
 	}
+	settingsChanged := m.mergeClusterLocked(cluster)
 	evicted := false
 	for _, in := range records {
 		changed, ev := m.mergeRecordLocked(in)
@@ -367,8 +477,12 @@ func (m *Manager) mergeNodes(records []NodeRecord) {
 		}
 	}
 	m.gcTombstonesLocked()
+	cb := m.OnClusterChange
 	m.mu.Unlock()
 
+	if settingsChanged && cb != nil {
+		cb()
+	}
 	if evicted {
 		logger.PrintAndLog("Cluster", "This node has been removed from the cluster by another member", nil)
 		m.wipeLocalState()
@@ -553,7 +667,7 @@ func (m *Manager) RemoveNode(nodeID string) error {
 		if target != nil {
 			m.notifyEvicted(ctx, target)
 		}
-		m.broadcast(peers, acn.BasePath+"/members/sync", SyncRequest{Nodes: m.snapshotRecords()}, 15*time.Second)
+		m.broadcast(peers, acn.BasePath+"/members/sync", m.syncPayload(), 15*time.Second)
 	}()
 	logger.PrintAndLog("Cluster", "Removed node "+nodeID+" from the cluster", nil)
 	return nil
@@ -598,7 +712,7 @@ func (m *Manager) SetNodeAdminState(nodeID string, state string) error {
 	m.store.saveNode(rec)
 	peers := m.peerIDsLocked()
 	m.mu.Unlock()
-	go m.broadcast(peers, acn.BasePath+"/members/sync", SyncRequest{Nodes: m.snapshotRecords()}, 15*time.Second)
+	go m.broadcast(peers, acn.BasePath+"/members/sync", m.syncPayload(), 15*time.Second)
 	return nil
 }
 
@@ -652,7 +766,7 @@ func (m *Manager) UpdateConfig(cfg LocalConfig) error {
 		case cfg.AdvertiseURL == "" && old.TunnelVia != cfg.TunnelVia:
 			m.tunnel.Reconnect()
 		}
-		go m.broadcast(peers, acn.BasePath+"/members/sync", SyncRequest{Nodes: m.snapshotRecords()}, 15*time.Second)
+		go m.broadcast(peers, acn.BasePath+"/members/sync", m.syncPayload(), 15*time.Second)
 	}
 	return nil
 }
@@ -908,6 +1022,7 @@ func (m *Manager) heartbeatAll() {
 		return
 	}
 	local := m.localSnapshotLocked()
+	cluster := m.clusterCopyLocked()
 	peers := m.peerIDsLocked()
 	m.mu.RUnlock()
 
@@ -919,13 +1034,13 @@ func (m *Manager) heartbeatAll() {
 			ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
 			defer cancel()
 			var resp HeartbeatResponse
-			err := m.transport.DoJSON(ctx, peerID, http.MethodPost, acn.BasePath+"/heartbeat", HeartbeatRequest{Node: local}, &resp)
+			err := m.transport.DoJSON(ctx, peerID, http.MethodPost, acn.BasePath+"/heartbeat", HeartbeatRequest{Node: local, Cluster: cluster}, &resp)
 			m.noteReachability(peerID, err)
 			if err != nil {
 				return
 			}
 			m.markSeen(peerID)
-			m.mergeNodes(resp.Nodes)
+			m.mergeGossip(resp.Cluster, resp.Nodes)
 		}(id)
 	}
 	wg.Wait()
@@ -1028,7 +1143,7 @@ func (m *Manager) onTunnelState(connected bool, host acn.TunnelHost) {
 	}
 	peers := m.peerIDsLocked()
 	m.mu.Unlock()
-	go m.broadcast(peers, acn.BasePath+"/members/sync", SyncRequest{Nodes: m.snapshotRecords()}, 15*time.Second)
+	go m.broadcast(peers, acn.BasePath+"/members/sync", m.syncPayload(), 15*time.Second)
 }
 
 // onTunnelConnect runs on the host side when a NAT-only node attaches.

+ 6 - 2
src/mod/cluster/membership/store.go

@@ -24,6 +24,10 @@ const (
 	tableNodes      = "nodes"
 	tableJoinTokens = "jointokens"
 	tableConfig     = "config"
+
+	// TableIdentity is the cluster.db table used by the identity service for
+	// its cluster-scoped records; it is wiped together with the membership.
+	TableIdentity = "identity"
 )
 
 // store wraps the key-value database with typed accessors.
@@ -45,7 +49,7 @@ func newStore(dbfile string) (*store, error) {
 	if err != nil {
 		return nil, err
 	}
-	for _, table := range []string{tableCluster, tableNodes, tableJoinTokens, tableConfig} {
+	for _, table := range []string{tableCluster, tableNodes, tableJoinTokens, tableConfig, TableIdentity} {
 		if err := db.NewTable(table); err != nil {
 			db.Close()
 			return nil, err
@@ -135,7 +139,7 @@ func (s *store) deleteJoinToken(id string) error {
 
 // wipeCluster removes every cluster-scoped record but keeps the local config.
 func (s *store) wipeCluster() error {
-	for _, table := range []string{tableCluster, tableNodes, tableJoinTokens} {
+	for _, table := range []string{tableCluster, tableNodes, tableJoinTokens, TableIdentity} {
 		if err := s.db.DropTable(table); err != nil {
 			return err
 		}

+ 14 - 5
src/mod/cluster/membership/types.go

@@ -140,11 +140,17 @@ type NodeView struct {
 	Tunnel bool      `json:"tunnel"` //true when this node currently terminates the peer's tunnel
 }
 
-// ClusterInfo identifies the cluster itself.
+// ClusterInfo identifies the cluster itself plus the cluster-wide settings
+// that are replicated to every member (last-writer-wins on SettingsVersion).
 type ClusterInfo struct {
 	ID      string `json:"id"`
 	Name    string `json:"name"`
 	Created int64  `json:"created"`
+
+	//IdentityOrigin is the node that verifies logins for the whole cluster
+	//(the SSO owner). Empty means every node authenticates on its own.
+	IdentityOrigin  string `json:"identityOrigin"`
+	SettingsVersion int64  `json:"settingsVersion"`
 }
 
 // LocalConfig is the operator-set configuration of this node.
@@ -180,16 +186,19 @@ type JoinResponse struct {
 }
 
 type HeartbeatRequest struct {
-	Node NodeRecord `json:"node"`
+	Node    NodeRecord   `json:"node"`
+	Cluster *ClusterInfo `json:"cluster,omitempty"`
 }
 
 type HeartbeatResponse struct {
-	Nodes []NodeRecord `json:"nodes"`
-	Time  int64        `json:"time"`
+	Nodes   []NodeRecord `json:"nodes"`
+	Cluster *ClusterInfo `json:"cluster,omitempty"`
+	Time    int64        `json:"time"`
 }
 
 type SyncRequest struct {
-	Nodes []NodeRecord `json:"nodes"`
+	Nodes   []NodeRecord `json:"nodes"`
+	Cluster *ClusterInfo `json:"cluster,omitempty"`
 }
 
 type NodeIDRequest struct {

+ 2 - 0
src/user.go

@@ -330,6 +330,7 @@ func user_handleUserEdit(w http.ResponseWriter, r *http.Request) {
 			utils.SendErrorResponse(w, err.Error())
 			return
 		}
+		clusterNotifyPasswordChanged(username, hashedPassword)
 		//Finish. Send back the reseted password
 		utils.SendJSONResponse(w, "\""+tmppassword+"\"")
 
@@ -473,6 +474,7 @@ func user_handleUserInfo(w http.ResponseWriter, r *http.Request) {
 		//OK! Change user password
 		newHashedPassword := auth.Hash(newpw)
 		sysdb.Write("auth", "passhash/"+username, newHashedPassword)
+		clusterNotifyPasswordChanged(username, newHashedPassword)
 		utils.SendOK(w)
 	} else if opr == "changeprofilepic" {
 		picdata, _ := utils.PostPara(r, "picdata")

+ 76 - 0
src/web/SystemAO/cluster/cluster.html

@@ -266,6 +266,17 @@
                         </div>
                     </div>
                     <ul class="cl-inline-list" id="token-list"></ul>
+                    <div class="cl-sep"></div>
+                    <div class="cl-field">
+                        <label id="lbl-identity">Identity (single sign-on)</label>
+                        <div class="cl-row">
+                            <select id="id-origin"><option value="">Disabled (each node checks its own accounts)</option></select>
+                            <button class="cl-btn cl-fixed" onclick="setIdentityOrigin()"><span id="lbl-idapply">Apply</span></button>
+                            <button class="cl-btn cl-fixed" id="btn-idsync" onclick="syncIdentity()"><span id="lbl-idsync">Sync now</span></button>
+                        </div>
+                        <div class="cl-hint" id="hint-identity">The identity origin verifies every login in the cluster. Accounts are copied to the other nodes so they still work when the origin is unreachable. Groups are matched by name, so create the same permission groups on every node.</div>
+                        <dl class="cl-kv" id="id-details" style="margin-top:10px"></dl>
+                    </div>
                     <div class="cl-msg" id="msg-member"></div>
                 </div>
             </div>
@@ -493,12 +504,76 @@
         document.getElementById('nodes-body').innerHTML = html || '<tr><td colspan="7" class="cl-empty">' + t('cluster/nonodes', 'No nodes') + '</td></tr>';
     }
 
+    /* Identity */
+    var identityStatus = null;
+    var identityDirty = false;
+    function loadIdentity() {
+        $.getJSON(API + 'identity/status', function(data) {
+            if (apiResult(data)) return;
+            identityStatus = data;
+            renderIdentity();
+        });
+    }
+    function renderIdentity() {
+        if (!clusterStatus || !clusterStatus.inCluster || !identityStatus) return;
+        var st = identityStatus;
+        var sel = document.getElementById('id-origin');
+        var prev = identityDirty ? sel.value : (st.origin || '');
+        var html = '<option value="">' + t('cluster/id/disabled', 'Disabled (each node checks its own accounts)') + '</option>';
+        (clusterStatus.nodes || []).forEach(function(n) {
+            html += '<option value="' + esc(n.id) + '"' + (n.id === prev ? ' selected' : '') + '>' + esc(n.name) + (n.local ? ' (' + t('cluster/thisnode', 'This node') + ')' : '') + '</option>';
+        });
+        sel.innerHTML = html;
+        sel.value = prev;
+        document.getElementById('btn-idsync').disabled = !st.enabled || st.isOrigin;
+
+        var rows = '';
+        if (!st.enabled) {
+            rows += '<dt>' + t('cluster/id/mode', 'Mode') + '</dt><dd>' + t('cluster/id/local', 'Each node verifies logins against its own accounts') + '</dd>';
+        } else if (st.isOrigin) {
+            rows += '<dt>' + t('cluster/id/mode', 'Mode') + '</dt><dd><span class="cl-badge ONLINE">' + t('cluster/id/origin', 'ORIGIN') + '</span> ' + t('cluster/id/originhere', 'This node verifies logins for the whole cluster') + '</dd>';
+            rows += '<dt>' + t('cluster/id/accounts', 'Accounts') + '</dt><dd>' + st.localAccounts + ' ' + t('cluster/id/published', 'published to members') + '</dd>';
+        } else {
+            rows += '<dt>' + t('cluster/id/mode', 'Mode') + '</dt><dd><span class="cl-badge ' + (st.lastForwardError ? 'DEGRADED' : 'ONLINE') + '">' + t('cluster/id/forward', 'FORWARD') + '</span> ' + t('cluster/id/verifiedby', 'Logins verified by') + ' ' + esc(st.originName) + '</dd>';
+            rows += '<dt>' + t('cluster/id/replicated', 'Replicated') + '</dt><dd>' + st.managedAccounts + ' ' + t('cluster/id/ofaccounts', 'of') + ' ' + st.localAccounts + ' ' + t('cluster/id/localaccounts', 'local accounts mirror the origin') + '</dd>';
+            rows += '<dt>' + t('cluster/id/lastsync', 'Last sync') + '</dt><dd>' + (st.lastSync ? fmtAgo(st.lastSync) : t('cluster/never', 'never')) + (st.lastSyncError ? ' <span style="color:var(--cl-danger)">' + esc(st.lastSyncError) + '</span>' : '') + '</dd>';
+            if (st.lastForwardError) {
+                rows += '<dt>' + t('cluster/id/lastlogin', 'Last login check') + '</dt><dd><span style="color:var(--cl-warn)">' + t('cluster/id/fallback', 'origin unreachable, local accounts used') + '</span><div class="cl-node-meta">' + esc(st.lastForwardError) + '</div></dd>';
+            }
+            if (st.skipped && st.skipped.length) {
+                rows += '<dt>' + t('cluster/id/skipped', 'Skipped') + '</dt><dd>' + esc(st.skipped.join(', ')) + '<div class="cl-node-meta">' + t('cluster/id/skippedwhy', 'none of their groups exist on this node') + '</div></dd>';
+            }
+            if (st.conflicts && st.conflicts.length) {
+                rows += '<dt>' + t('cluster/id/conflicts', 'Local only') + '</dt><dd>' + esc(st.conflicts.join(', ')) + '<div class="cl-node-meta">' + t('cluster/id/conflictswhy', 'a local account with the same name is kept as is') + '</div></dd>';
+            }
+        }
+        document.getElementById('id-details').innerHTML = rows;
+    }
+    function setIdentityOrigin() {
+        apiPost('identity/origin', { id: document.getElementById('id-origin').value }, function(err, r) {
+            var e = err || apiResult(r);
+            if (e) { showMsg('msg-member', e, false); return; }
+            identityDirty = false;
+            showMsg('msg-member', t('cluster/saved', 'Saved'), true);
+            loadStatus();
+        });
+    }
+    function syncIdentity() {
+        apiPost('identity/sync', {}, function(err, r) {
+            var e = err || apiResult(r);
+            if (e) { showMsg('msg-member', e, false); return; }
+            showMsg('msg-member', t('cluster/id/synced', 'Accounts synchronised'), true);
+            loadStatus();
+        });
+    }
+
     /* Actions */
     function loadStatus() {
         $.getJSON(API + 'status', function(data) {
             if (apiResult(data)) return;
             clusterStatus = data;
             render();
+            if (data.inCluster) loadIdentity();
         });
     }
     function saveConfig() {
@@ -616,6 +691,7 @@
         document.getElementById(id).addEventListener('input', function() { configDirty = true; toggleTunnelField(); });
         document.getElementById(id).addEventListener('change', function() { configDirty = true; toggleTunnelField(); });
     });
+    document.getElementById('id-origin').addEventListener('change', function() { identityDirty = true; });
     loadStatus();
     setInterval(loadStatus, 10000);
 </script>