Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions cmd/grlx/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ var authCmd = &cobra.Command{
}

func init() {
authCmd.AddCommand(authLoginCmd)
authCmd.AddCommand(authPrivKeyCmd)
authCmd.AddCommand(authPubKeyCmd)
authCmd.AddCommand(authTokenCmd)
Expand All @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions internal/api/client/login.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions internal/api/client/login_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
12 changes: 12 additions & 0 deletions internal/api/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions internal/audit/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion internal/audit/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
62 changes: 62 additions & 0 deletions internal/natsapi/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading