From 8401c4eee10663a2c8659446d4660aeadb6a771a Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Tue, 31 Mar 2026 10:38:51 +0000 Subject: [PATCH] feat(auth): add CLI auth login flow with farmer validation and audit wiring Add auth.login NATS endpoint that provides a formal CLI-to-farmer authentication handshake. The CLI presents its signed token (containing the user's public key) and the farmer validates it against configured users, returning identity, role, permissions, and admin status. Changes: - Add auth.login NATS subject and handler (public method, handles own auth) - Add LoginResponse type with full permission summary - Add client.Login() and client.ValidateAuth() for CLI-side auth - Add 'grlx auth login' CLI command for explicit identity verification - Wire auth.login through RBAC middleware (ActionUserRead) - Register auth.login as read-only action in audit system - Add comprehensive tests for handler, client, middleware, and audit --- cmd/grlx/cmd/auth.go | 59 +++++++++++++ internal/api/client/login.go | 41 +++++++++ internal/api/client/login_test.go | 58 +++++++++++++ internal/api/types/types.go | 12 +++ internal/audit/middleware.go | 1 + internal/audit/middleware_test.go | 2 +- internal/natsapi/auth.go | 62 ++++++++++++++ internal/natsapi/coverage_test.go | 137 ++++++++++++++++++++++++++++++ internal/natsapi/middleware.go | 2 + internal/natsapi/router.go | 1 + internal/natsapi/subjects.go | 7 +- 11 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 internal/api/client/login.go create mode 100644 internal/api/client/login_test.go diff --git a/cmd/grlx/cmd/auth.go b/cmd/grlx/cmd/auth.go index 74c982c..b963fe7 100644 --- a/cmd/grlx/cmd/auth.go +++ b/cmd/grlx/cmd/auth.go @@ -22,6 +22,7 @@ var authCmd = &cobra.Command{ } func init() { + authCmd.AddCommand(authLoginCmd) authCmd.AddCommand(authPrivKeyCmd) authCmd.AddCommand(authPubKeyCmd) authCmd.AddCommand(authTokenCmd) @@ -32,6 +33,64 @@ func init() { rootCmd.AddCommand(authCmd) } +var authLoginCmd = &cobra.Command{ + Use: "login", + Short: "Verify the CLI key is recognized by the farmer and show identity", + Long: `Presents the CLI's public key to the farmer over NATS for +authentication. The farmer validates the key against configured users +and returns the user's identity, role, and permissions. + +This is useful to verify connectivity and auth before running commands.`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + result, err := client.Login() + switch outputMode { + case "json": + if err != nil { + errResult := struct { + Authenticated bool `json:"authenticated"` + Error string `json:"error"` + }{Authenticated: false, Error: err.Error()} + jw, _ := json.Marshal(errResult) + fmt.Println(string(jw)) + os.Exit(1) + } + jw, _ := json.Marshal(result) + fmt.Println(string(jw)) + case "": + fallthrough + case "text": + if err != nil { + log.Printf("Login failed: %s", err.Error()) + os.Exit(1) + } + if !result.Authenticated { + log.Printf("Login failed: %s", result.Message) + os.Exit(1) + } + if result.Username != "" { + fmt.Printf("User: %s\n", result.Username) + } + fmt.Printf("Pubkey: %s\n", result.Pubkey) + fmt.Printf("Role: %s\n", result.RoleName) + if result.IsAdmin { + fmt.Println("Admin: yes") + } + if len(result.Actions) > 0 { + fmt.Println("\nPermissions:") + for _, a := range result.Actions { + if a.Scope == "" || a.Scope == "*" { + fmt.Printf(" %s (all)\n", a.Action) + } else { + fmt.Printf(" %s → %s\n", a.Action, a.Scope) + } + } + } + fmt.Printf("\n%s\n", result.Message) + } + }, +} + var authWhoAmICmd = &cobra.Command{ Use: "whoami", Short: "Show the identity and role of the current CLI user", diff --git a/internal/api/client/login.go b/internal/api/client/login.go new file mode 100644 index 0000000..776c473 --- /dev/null +++ b/internal/api/client/login.go @@ -0,0 +1,41 @@ +package client + +import ( + "encoding/json" + "fmt" + + apitypes "github.com/gogrlx/grlx/v2/internal/api/types" +) + +// Login performs a formal auth handshake with the farmer. The CLI +// presents its signed token (containing the public key) and the farmer +// validates it against configured users, returning the user's identity, +// role, and permissions. +// +// This is distinct from WhoAmI in that it returns the full permission +// set and is intended as an explicit "login" action. +func Login() (apitypes.LoginResponse, error) { + var result apitypes.LoginResponse + resp, err := NatsRequest("auth.login", nil) + if err != nil { + return result, err + } + if err := json.Unmarshal(resp, &result); err != nil { + return result, fmt.Errorf("login: %w", err) + } + return result, nil +} + +// ValidateAuth performs a lightweight auth check by calling Login and +// returning an error if the CLI's key is not recognized by the farmer. +// This is used during CLI startup to catch misconfigured keys early. +func ValidateAuth() (apitypes.LoginResponse, error) { + result, err := Login() + if err != nil { + return result, fmt.Errorf("auth validation failed: %w", err) + } + if !result.Authenticated { + return result, fmt.Errorf("auth validation failed: %s", result.Message) + } + return result, nil +} diff --git a/internal/api/client/login_test.go b/internal/api/client/login_test.go new file mode 100644 index 0000000..b06959a --- /dev/null +++ b/internal/api/client/login_test.go @@ -0,0 +1,58 @@ +package client + +import ( + "encoding/json" + "testing" + + apitypes "github.com/gogrlx/grlx/v2/internal/api/types" +) + +func TestLoginResponseUnmarshal(t *testing.T) { + data := `{ + "authenticated": true, + "pubkey": "AXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "role": "admin", + "username": "alice", + "isAdmin": true, + "actions": [{"action": "admin", "scope": "*"}], + "message": "authenticated as alice (role: admin)" + }` + + var resp apitypes.LoginResponse + if err := json.Unmarshal([]byte(data), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !resp.Authenticated { + t.Error("expected authenticated=true") + } + if resp.RoleName != "admin" { + t.Errorf("expected role=admin, got %s", resp.RoleName) + } + if resp.Username != "alice" { + t.Errorf("expected username=alice, got %s", resp.Username) + } + if !resp.IsAdmin { + t.Error("expected isAdmin=true") + } + if len(resp.Actions) != 1 { + t.Errorf("expected 1 action, got %d", len(resp.Actions)) + } +} + +func TestLoginResponseUnauthenticated(t *testing.T) { + data := `{ + "authenticated": false, + "message": "no token provided" + }` + + var resp apitypes.LoginResponse + if err := json.Unmarshal([]byte(data), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Authenticated { + t.Error("expected authenticated=false") + } + if resp.Message != "no token provided" { + t.Errorf("expected message='no token provided', got %q", resp.Message) + } +} diff --git a/internal/api/types/types.go b/internal/api/types/types.go index bc014de..7483ab7 100644 --- a/internal/api/types/types.go +++ b/internal/api/types/types.go @@ -70,6 +70,18 @@ type UserInfo struct { Username string `json:"username,omitempty"` } +// LoginResponse is returned by the auth.login endpoint. It confirms +// the CLI's identity and provides the user's role and permissions. +type LoginResponse struct { + Authenticated bool `json:"authenticated"` + Pubkey string `json:"pubkey"` + RoleName string `json:"role"` + Username string `json:"username,omitempty"` + IsAdmin bool `json:"isAdmin"` + Actions []ActionExplain `json:"actions,omitempty"` + Message string `json:"message,omitempty"` +} + // RoleInfo describes a role and its rules. type RoleInfo struct { Name string `json:"name"` diff --git a/internal/audit/middleware.go b/internal/audit/middleware.go index e00db4f..c240c05 100644 --- a/internal/audit/middleware.go +++ b/internal/audit/middleware.go @@ -76,6 +76,7 @@ var readOnlyActions = map[string]bool{ "props.get": true, "cohorts.list": true, "cohorts.get": true, + "auth.login": true, "auth.whoami": true, "auth.users": true, "pki.list": true, diff --git a/internal/audit/middleware_test.go b/internal/audit/middleware_test.go index 42102aa..b670787 100644 --- a/internal/audit/middleware_test.go +++ b/internal/audit/middleware_test.go @@ -10,7 +10,7 @@ import ( ) func TestIsReadOnly(t *testing.T) { - readOnly := []string{"version", "sprouts.list", "jobs.list", "auth.whoami"} + readOnly := []string{"version", "sprouts.list", "jobs.list", "auth.login", "auth.whoami"} for _, a := range readOnly { if !IsReadOnly(a) { t.Errorf("IsReadOnly(%q) = false, want true", a) diff --git a/internal/natsapi/auth.go b/internal/natsapi/auth.go index abf3e84..bea1bd2 100644 --- a/internal/natsapi/auth.go +++ b/internal/natsapi/auth.go @@ -14,6 +14,68 @@ type AuthParams struct { Token string `json:"token"` } +// handleAuthLogin validates the CLI user's identity by verifying the +// embedded token. It returns the user's role, permissions, and admin +// status — a formal "handshake" that confirms the CLI's key is +// recognized by the farmer before the user runs any real commands. +func handleAuthLogin(params json.RawMessage) (any, error) { + var p AuthParams + if len(params) > 0 { + json.Unmarshal(params, &p) + } + + if p.Token == "" { + if intauth.DangerouslyAllowRoot() { + return apitypes.LoginResponse{ + Authenticated: true, + Pubkey: "(dangerously_allow_root)", + RoleName: "admin", + IsAdmin: true, + Message: "authenticated (dangerously_allow_root)", + }, nil + } + return apitypes.LoginResponse{ + Authenticated: false, + Message: "no token provided", + }, fmt.Errorf("unauthorized: no token provided") + } + + pubkey, roleName, username, err := intauth.WhoAmI(p.Token) + if err != nil { + return apitypes.LoginResponse{ + Authenticated: false, + Message: "invalid or expired token", + }, fmt.Errorf("unauthorized: %w", err) + } + + // Build permission summary. + policy := intauth.CurrentPolicy() + summary := rbac.ExplainAccess(policy, pubkey) + + actions := make([]apitypes.ActionExplain, 0, len(summary.Actions)) + for _, a := range summary.Actions { + actions = append(actions, apitypes.ActionExplain{ + Action: a.Action, + Scope: a.Scope, + }) + } + + displayName := username + if displayName == "" { + displayName = pubkey[:12] + "..." + } + + return apitypes.LoginResponse{ + Authenticated: true, + Pubkey: pubkey, + RoleName: roleName, + Username: username, + IsAdmin: summary.IsAdmin, + Actions: actions, + Message: fmt.Sprintf("authenticated as %s (role: %s)", displayName, roleName), + }, nil +} + func handleAuthWhoAmI(params json.RawMessage) (any, error) { var p AuthParams if len(params) > 0 { diff --git a/internal/natsapi/coverage_test.go b/internal/natsapi/coverage_test.go index 9682a34..8671000 100644 --- a/internal/natsapi/coverage_test.go +++ b/internal/natsapi/coverage_test.go @@ -1256,3 +1256,140 @@ func TestResolveCallerIdentityValidToken(t *testing.T) { t.Errorf("role = %q, want admin", role) } } + +// --- handleAuthLogin tests --- + +func TestHandleAuthLoginDangerouslyAllowRoot(t *testing.T) { + cleanup := setupJetyDangerouslyAllowRoot(t, true) + defer cleanup() + + result, err := handleAuthLogin(json.RawMessage(`{}`)) + if err != nil { + t.Fatalf("handleAuthLogin: %v", err) + } + + b, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(b, &resp) + + if resp["authenticated"] != true { + t.Errorf("authenticated = %v, want true", resp["authenticated"]) + } + if resp["pubkey"] != "(dangerously_allow_root)" { + t.Errorf("pubkey = %v, want (dangerously_allow_root)", resp["pubkey"]) + } + if resp["role"] != "admin" { + t.Errorf("role = %v, want admin", resp["role"]) + } + if resp["isAdmin"] != true { + t.Errorf("isAdmin = %v, want true", resp["isAdmin"]) + } +} + +func TestHandleAuthLoginNoToken(t *testing.T) { + cleanup := setupJetyDangerouslyAllowRoot(t, false) + defer cleanup() + + _, err := handleAuthLogin(json.RawMessage(`{}`)) + if err == nil { + t.Fatal("expected error for no token") + } +} + +func TestHandleAuthLoginEmptyParams(t *testing.T) { + cleanup := setupJetyDangerouslyAllowRoot(t, false) + defer cleanup() + + _, err := handleAuthLogin(nil) + if err == nil { + t.Fatal("expected error for nil params") + } +} + +func TestHandleAuthLoginValidToken(t *testing.T) { + token, cleanup := setupAuthWithToken(t, "operator", []rbac.Rule{ + {Action: rbac.ActionCook, Scope: "*"}, + {Action: rbac.ActionView, Scope: "*"}, + }) + defer cleanup() + + params, _ := json.Marshal(map[string]string{"token": token}) + result, err := handleAuthLogin(params) + if err != nil { + t.Fatalf("handleAuthLogin: %v", err) + } + + b, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(b, &resp) + + if resp["authenticated"] != true { + t.Errorf("authenticated = %v, want true", resp["authenticated"]) + } + if resp["role"] != "operator" { + t.Errorf("role = %v, want operator", resp["role"]) + } + if resp["isAdmin"] == true { + t.Error("expected isAdmin=false for operator role") + } + actions, ok := resp["actions"].([]interface{}) + if !ok { + t.Fatalf("actions type = %T, want []interface{}", resp["actions"]) + } + if len(actions) < 1 { + t.Error("expected at least 1 action in response") + } +} + +func TestHandleAuthLoginInvalidToken(t *testing.T) { + cleanup := setupJetyDangerouslyAllowRoot(t, false) + defer cleanup() + + params, _ := json.Marshal(map[string]string{"token": "not-a-real-token"}) + _, err := handleAuthLogin(params) + if err == nil { + t.Fatal("expected error for invalid token") + } +} + +func TestHandleAuthLoginAdminToken(t *testing.T) { + token, cleanup := setupAuthWithToken(t, "admin", []rbac.Rule{ + {Action: rbac.ActionAdmin, Scope: "*"}, + }) + defer cleanup() + + params, _ := json.Marshal(map[string]string{"token": token}) + result, err := handleAuthLogin(params) + if err != nil { + t.Fatalf("handleAuthLogin: %v", err) + } + + b, _ := json.Marshal(result) + var resp map[string]interface{} + json.Unmarshal(b, &resp) + + if resp["authenticated"] != true { + t.Errorf("authenticated = %v, want true", resp["authenticated"]) + } + if resp["isAdmin"] != true { + t.Errorf("isAdmin = %v, want true", resp["isAdmin"]) + } + if resp["role"] != "admin" { + t.Errorf("role = %v, want admin", resp["role"]) + } +} + +func TestHandleAuthLoginIsPublicMethod(t *testing.T) { + // auth.login should be in the publicMethods map. + if !publicMethods[MethodAuthLogin] { + t.Error("auth.login should be a public method") + } +} + +func TestHandleAuthLoginMiddlewareAction(t *testing.T) { + // auth.login should map to ActionUserRead in the natsActionMap. + action := NATSMethodAction(MethodAuthLogin) + if action != rbac.ActionUserRead { + t.Errorf("NATSMethodAction(auth.login) = %v, want %v", action, rbac.ActionUserRead) + } +} diff --git a/internal/natsapi/middleware.go b/internal/natsapi/middleware.go index 10fec0d..64bf59b 100644 --- a/internal/natsapi/middleware.go +++ b/internal/natsapi/middleware.go @@ -44,6 +44,7 @@ var natsActionMap = map[string]rbac.Action{ MethodPKIDelete: rbac.ActionPKI, // Auth + MethodAuthLogin: rbac.ActionUserRead, MethodAuthWhoAmI: rbac.ActionUserRead, MethodAuthListUsers: rbac.ActionAdmin, MethodAuthAddUser: rbac.ActionAdmin, @@ -63,6 +64,7 @@ var natsActionMap = map[string]rbac.Action{ // version is informational; auth.whoami handles its own auth logic. var publicMethods = map[string]bool{ MethodVersion: true, + MethodAuthLogin: true, MethodAuthWhoAmI: true, MethodAuthExplain: true, } diff --git a/internal/natsapi/router.go b/internal/natsapi/router.go index accbafe..7a63e72 100644 --- a/internal/natsapi/router.go +++ b/internal/natsapi/router.go @@ -75,6 +75,7 @@ var routes = map[string]handler{ MethodCohortsValidate: handleCohortsValidate, // Auth + MethodAuthLogin: handleAuthLogin, MethodAuthWhoAmI: handleAuthWhoAmI, MethodAuthListUsers: handleAuthListUsers, MethodAuthAddUser: handleAuthAddUser, diff --git a/internal/natsapi/subjects.go b/internal/natsapi/subjects.go index 2622353..456bb1c 100644 --- a/internal/natsapi/subjects.go +++ b/internal/natsapi/subjects.go @@ -6,6 +6,7 @@ package natsapi import ( + apitypes "github.com/gogrlx/grlx/v2/internal/api/types" "github.com/gogrlx/grlx/v2/internal/audit" "github.com/gogrlx/grlx/v2/internal/config" "github.com/gogrlx/grlx/v2/internal/jobs" @@ -72,6 +73,7 @@ const ( MethodCohortsValidate = "cohorts.validate" // Auth + MethodAuthLogin = "auth.login" MethodAuthWhoAmI = "auth.whoami" MethodAuthListUsers = "auth.users" MethodAuthAddUser = "auth.users.add" @@ -154,6 +156,9 @@ type CohortRefreshRequest = CohortRefreshParams // AuthTokenRequest holds a token for auth operations. type AuthTokenRequest = AuthParams +// AuthLoginResponse is the response for the auth.login endpoint. +type AuthLoginResponse = apitypes.LoginResponse + // ShellStartRequest is the request to start an interactive shell session. type ShellStartRequest = shell.CLIStartRequest @@ -268,7 +273,7 @@ func AllMethods() []string { MethodJobsList, MethodJobsGet, MethodJobsCancel, MethodJobsForSprout, MethodPropsGetAll, MethodPropsGet, MethodPropsSet, MethodPropsDelete, MethodCohortsList, MethodCohortsGet, MethodCohortsResolve, MethodCohortsRefresh, MethodCohortsValidate, - MethodAuthWhoAmI, MethodAuthListUsers, MethodAuthAddUser, MethodAuthRemoveUser, MethodAuthExplain, + MethodAuthLogin, MethodAuthWhoAmI, MethodAuthListUsers, MethodAuthAddUser, MethodAuthRemoveUser, MethodAuthExplain, MethodShellStart, MethodRecipesList, MethodRecipesGet, MethodAuditDates, MethodAuditQuery,