mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
M2: admin console over ssh admin@ (users, sessions, moderation, plugins) (#3)
A privileged operator console reached as `ssh admin@host`, gated by route
plus the $AGENTBBS_ADMINS allowlist (admin status is operator-granted only,
never self-assigned in-band). It is a self-contained Bubble Tea model, not a
hub plugin, so it never appears in the public menu.
Sections (PRD §6):
- Users & members: list accounts; b = suspend/ban (operators protected).
Banned accounts are blocked at the hub and pod@ routes.
- Sessions & pods: live in-memory session registry; k = disconnect.
- Moderation & audit: admin action log + agent@ transcripts (tab to switch).
- Config & plugins: runtime snapshot; space = enable/disable a plugin
(persisted; filtered from the hub on next sign-in).
Every privileged action is written to a new admin_actions audit table.
store: + banned column, admin_actions and plugin_state tables, and the
backing methods (ListUsers/SetBanned/RecentSessions/LogAdminAction/
RecentAdminActions/RecentChatsAll/DisabledPlugins/SetPluginDisabled), with
unit tests. auth: admin allowlist helpers + tests. Docs in docs/admin.md;
README M2 flipped to done.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ceaf055e0e
commit
232b8151a2
9 changed files with 1174 additions and 5 deletions
|
|
@ -2,6 +2,7 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/ssh"
|
||||
|
|
@ -40,6 +41,11 @@ var JoinNames = map[string]bool{"join": true, "signup": true, "register": true}
|
|||
// list/add/remove the domains pointed at a member's homepage.
|
||||
var DomainNames = map[string]bool{"domain": true, "domains": true}
|
||||
|
||||
// AdminNames are usernames that route to the privileged admin console (PRD §6).
|
||||
// The route only opens for accounts whose name is in the operator allowlist
|
||||
// (see IsAdmin); the name itself confers nothing.
|
||||
var AdminNames = map[string]bool{"admin": true, "sysop": true}
|
||||
|
||||
// IsGuestName reports whether the SSH username requests anonymous hub access.
|
||||
func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] }
|
||||
|
||||
|
|
@ -52,6 +58,25 @@ func IsJoinName(u string) bool { return JoinNames[strings.ToLower(u)] }
|
|||
// IsDomainName reports whether the SSH username requests the custom-domain flow.
|
||||
func IsDomainName(u string) bool { return DomainNames[strings.ToLower(u)] }
|
||||
|
||||
// IsAdminName reports whether the SSH username requests the admin console.
|
||||
func IsAdminName(u string) bool { return AdminNames[strings.ToLower(u)] }
|
||||
|
||||
// Admins returns the operator-configured admin allowlist: the lowercased,
|
||||
// comma/space-separated account names in $AGENTBBS_ADMINS. Admin status can
|
||||
// only be granted by the operator (via env), never self-assigned in-band.
|
||||
func Admins() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, f := range strings.FieldsFunc(os.Getenv("AGENTBBS_ADMINS"), func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n'
|
||||
}) {
|
||||
out[strings.ToLower(f)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the account name is in the operator allowlist.
|
||||
func IsAdmin(name string) bool { return Admins()[strings.ToLower(name)] }
|
||||
|
||||
// KindFor infers the identity kind from a (non-guest) username.
|
||||
// Usernames prefixed "agent-" are automated clients (PRD §3).
|
||||
func KindFor(username string) Kind {
|
||||
|
|
|
|||
42
internal/auth/auth_test.go
Normal file
42
internal/auth/auth_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsAdminName(t *testing.T) {
|
||||
for _, name := range []string{"admin", "ADMIN", "sysop"} {
|
||||
if !IsAdminName(name) {
|
||||
t.Errorf("IsAdminName(%q) = false, want true", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"bbs", "pod", "anthony", ""} {
|
||||
if IsAdminName(name) {
|
||||
t.Errorf("IsAdminName(%q) = true, want false", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminsAllowlist(t *testing.T) {
|
||||
t.Setenv("AGENTBBS_ADMINS", "anthony, Root ops")
|
||||
admins := Admins()
|
||||
for _, want := range []string{"anthony", "root", "ops"} {
|
||||
if !admins[want] {
|
||||
t.Errorf("expected %q in allowlist, got %v", want, admins)
|
||||
}
|
||||
}
|
||||
if !IsAdmin("ANTHONY") {
|
||||
t.Error("IsAdmin should be case-insensitive")
|
||||
}
|
||||
if IsAdmin("eve") {
|
||||
t.Error("eve must not be an admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminsEmpty(t *testing.T) {
|
||||
t.Setenv("AGENTBBS_ADMINS", "")
|
||||
if len(Admins()) != 0 {
|
||||
t.Error("empty env should yield no admins")
|
||||
}
|
||||
if IsAdmin("anyone") {
|
||||
t.Error("nobody is admin when allowlist is empty")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue