8f0158eb9f
See CHANGELOG.md for the full breakdown. Summary:
- ldap_tunnel.go: serves a local unix socket for SSSD/PAM and relays raw
bytes to the SSO over the existing WSS channel (ldap_tunnel messages);
the agent never parses LDAP (DESIGN.md §4). Adds safeWriter to
serialize WebSocket writes now that telemetry, heartbeat, the LDAP
tunnel, and command responses all share one connection.
- secrets.go: renders local templates ({{ bao "path#key" }} placeholders)
by fetching node-scoped values from the SSO and writing the target
atomically at 0600, on a signed render_secrets command (DESIGN.md §5).
demo/ has minimal bash + Node consumers of the rendered file.
- iam.go: applies signed node IAM pushes -- sudoers.d rules (visudo -c
validated), SSH AuthorizedKeysCommand keys, /etc/security/access.conf,
and revocation via sss_cache -E + pkill -u (DESIGN.md §6).
- Capability reporting: the agent's enabled capabilities ride along in
its discovery frame so the SSO can show them in the Directory.
- DESIGN.md: the v2 protocol design this implements.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
128 lines
3.9 KiB
Go
128 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// TestApplyIAM verifies the agent applies sudo rules, SSH keys, access control,
|
|
// and revocation from a signed IAM payload.
|
|
func TestApplyIAM(t *testing.T) {
|
|
dir := t.TempDir()
|
|
iamSudoersDir = dir
|
|
iamKeysDir = filepath.Join(dir, "keys")
|
|
iamKeysCommand = filepath.Join(dir, "theta-authorized-keys")
|
|
iamAccessConf = filepath.Join(dir, "access.conf")
|
|
|
|
payload := IAMPayload{
|
|
NodeID: "node-42",
|
|
Revision: 82,
|
|
AccessControl: AccessControl{
|
|
AllowedLoginGroups: []string{"sysadmins", "node-operators"},
|
|
SudoRules: []SudoRule{
|
|
{Group: "sysadmins", RunAs: "ALL", Commands: []string{"ALL"}},
|
|
{Group: "node-operators", RunAs: "ALL", Commands: []string{"ALL"}, Nopasswd: true},
|
|
},
|
|
SSHKeys: []SSHKey{
|
|
{User: "admin", Keys: []string{"ssh-ed25519 AAAA admin@theta"}},
|
|
},
|
|
RevokeUsers: []string{"olduser"},
|
|
},
|
|
}
|
|
|
|
exec := &MockExecutor{}
|
|
if err := applyIAM(payload, exec); err != nil {
|
|
t.Fatalf("applyIAM: %v", err)
|
|
}
|
|
|
|
// Sudo rules file.
|
|
sudoers, err := os.ReadFile(filepath.Join(dir, "theta-iam-node-42"))
|
|
if err != nil {
|
|
t.Fatalf("read sudoers: %v", err)
|
|
}
|
|
wantSudoers := "%sysadmins ALL=(ALL) ALL\n%node-operators ALL=(ALL) NOPASSWD:ALL\n"
|
|
if string(sudoers) != wantSudoers {
|
|
t.Fatalf("sudoers mismatch:\n got: %q\nwant: %q", sudoers, wantSudoers)
|
|
}
|
|
|
|
// SSH key file + AuthorizedKeysCommand script.
|
|
keyFile, err := os.ReadFile(filepath.Join(iamKeysDir, "admin"))
|
|
if err != nil {
|
|
t.Fatalf("read key file: %v", err)
|
|
}
|
|
if string(keyFile) != "ssh-ed25519 AAAA admin@theta\n" {
|
|
t.Fatalf("key file mismatch: %q", keyFile)
|
|
}
|
|
script, err := os.ReadFile(iamKeysCommand)
|
|
if err != nil {
|
|
t.Fatalf("read keys command: %v", err)
|
|
}
|
|
if string(script) != "#!/bin/sh\ncat \""+iamKeysDir+"/$1\" 2>/dev/null\n" {
|
|
t.Fatalf("keys command mismatch: %q", script)
|
|
}
|
|
|
|
// Access control.
|
|
access, err := os.ReadFile(iamAccessConf)
|
|
if err != nil {
|
|
t.Fatalf("read access.conf: %v", err)
|
|
}
|
|
wantAccess := "+:sysadmins:ALL\n+:node-operators:ALL\n-:ALL:ALL\n"
|
|
if string(access) != wantAccess {
|
|
t.Fatalf("access.conf mismatch:\n got: %q\nwant: %q", access, wantAccess)
|
|
}
|
|
|
|
// Commands: visudo -c -f, sss_cache -E, pkill -u olduser.
|
|
ran := map[string]bool{}
|
|
for _, c := range exec.ExecutedCommands {
|
|
if c[0] == "visudo" {
|
|
ran["visudo"] = true
|
|
}
|
|
if c[0] == "sss_cache" {
|
|
ran["sss_cache"] = true
|
|
}
|
|
if c[0] == "pkill" && len(c) >= 3 && c[2] == "olduser" {
|
|
ran["pkill"] = true
|
|
}
|
|
}
|
|
for _, k := range []string{"visudo", "sss_cache", "pkill"} {
|
|
if !ran[k] {
|
|
t.Errorf("expected %s to run, got commands %v", k, exec.ExecutedCommands)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestParseIAMPayload verifies the payload parses from a WSMessage payload map.
|
|
func TestParseIAMPayload(t *testing.T) {
|
|
payload := map[string]interface{}{
|
|
"node_id": "node-42",
|
|
"revision": float64(82),
|
|
"access_control": map[string]interface{}{
|
|
"allowed_login_groups": []interface{}{"sysadmins"},
|
|
"sudo_rules": []interface{}{
|
|
map[string]interface{}{"group": "sysadmins", "run_as": "ALL", "commands": []interface{}{"ALL"}, "nopasswd": true},
|
|
},
|
|
"ssh_keys": []interface{}{
|
|
map[string]interface{}{"user": "admin", "keys": []interface{}{"ssh-ed25519 AAAA"}},
|
|
},
|
|
"revoke_users": []interface{}{"olduser"},
|
|
},
|
|
}
|
|
p, err := parseIAMPayload(payload)
|
|
if err != nil {
|
|
t.Fatalf("parseIAMPayload: %v", err)
|
|
}
|
|
if p.NodeID != "node-42" || p.Revision != 82 {
|
|
t.Fatalf("bad node/revision: %+v", p)
|
|
}
|
|
if len(p.AccessControl.SudoRules) != 1 || p.AccessControl.SudoRules[0].Group != "sysadmins" {
|
|
t.Fatalf("bad sudo rules: %+v", p.AccessControl.SudoRules)
|
|
}
|
|
if len(p.AccessControl.SSHKeys) != 1 || p.AccessControl.SSHKeys[0].User != "admin" {
|
|
t.Fatalf("bad ssh keys: %+v", p.AccessControl.SSHKeys)
|
|
}
|
|
if len(p.AccessControl.RevokeUsers) != 1 || p.AccessControl.RevokeUsers[0] != "olduser" {
|
|
t.Fatalf("bad revoke users: %+v", p.AccessControl.RevokeUsers)
|
|
}
|
|
}
|