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
8 changes: 8 additions & 0 deletions internal/cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
Authenticated: false,
Profile: profile,
AuthMethod: authMethod,
ProfileRole: config.ProfileRole(profile),
}

if authMethod == "service_account" {
Expand Down Expand Up @@ -457,6 +458,9 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
if status.Authenticated {
fmt.Fprintf(cmd.OutOrStdout(), "Authenticated: yes\n")
fmt.Fprintf(cmd.OutOrStdout(), "Profile: %s\n", status.Profile)
if status.ProfileRole != "" {
fmt.Fprintf(cmd.OutOrStdout(), "Profile Role: %s\n", status.ProfileRole)
}
fmt.Fprintf(cmd.OutOrStdout(), "Auth Method: %s\n", status.AuthMethod)
fmt.Fprintf(cmd.OutOrStdout(), "Org Name: %s\n", status.OrgName)
fmt.Fprintf(cmd.OutOrStdout(), "Org ID: %s\n", status.OrgID)
Expand All @@ -471,6 +475,9 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
} else {
fmt.Fprintf(cmd.OutOrStdout(), "Authenticated: no\n")
fmt.Fprintf(cmd.OutOrStdout(), "Profile: %s\n", status.Profile)
if status.ProfileRole != "" {
fmt.Fprintf(cmd.OutOrStdout(), "Profile Role: %s\n", status.ProfileRole)
}
fmt.Fprintf(cmd.OutOrStdout(), "Auth Method: %s\n", status.AuthMethod)
if status.AuthMethod == "service_account" {
fmt.Fprintf(cmd.OutOrStdout(), "Run 'jc auth login --service-account' to re-authenticate.\n")
Expand All @@ -486,6 +493,7 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
type authStatusInfo struct {
Authenticated bool `json:"authenticated"`
Profile string `json:"profile"`
ProfileRole string `json:"profile_role,omitempty"`
AuthMethod string `json:"auth_method"`
OrgName string `json:"org_name,omitempty"`
OrgID string `json:"org_id,omitempty"`
Expand Down
61 changes: 61 additions & 0 deletions internal/cmd/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,67 @@ profiles:
}
}

func TestAuthStatus_ShowsProfileRoleWhenSet(t *testing.T) {
keyring.MockInit()
setupTestConfig(t, `active_profile: reporting
profiles:
reporting:
api_key: ""
auth_profile_role: read_only
`)
viper.Set("defaults.output", "table")

ts := startMockJCServer(t, "org-ro", "Reporting Org", http.StatusOK)
defer ts.Close()
overrideAPIClient(t, ts.URL)

viper.Set("api_key", "readonly-key-9876")

cmd := &cobra.Command{}
stdout := new(bytes.Buffer)
cmd.SetOut(stdout)
cmd.SetErr(new(bytes.Buffer))

if err := runAuthStatus(cmd, nil); err != nil {
t.Fatalf("runAuthStatus() error: %v", err)
}

got := stdout.String()
if !strings.Contains(got, "Profile Role: read_only") {
t.Errorf("expected 'Profile Role: read_only' in output, got %q", got)
}
}

func TestAuthStatus_OmitsProfileRoleWhenUnset(t *testing.T) {
keyring.MockInit()
setupTestConfig(t, `active_profile: default
profiles:
default:
api_key: ""
`)
viper.Set("defaults.output", "table")

ts := startMockJCServer(t, "org-norole", "No Role Org", http.StatusOK)
defer ts.Close()
overrideAPIClient(t, ts.URL)

viper.Set("api_key", "test-key-norole-1234")

cmd := &cobra.Command{}
stdout := new(bytes.Buffer)
cmd.SetOut(stdout)
cmd.SetErr(new(bytes.Buffer))

if err := runAuthStatus(cmd, nil); err != nil {
t.Fatalf("runAuthStatus() error: %v", err)
}

got := stdout.String()
if strings.Contains(got, "Profile Role:") {
t.Errorf("did not expect 'Profile Role:' line when role is unset; got %q", got)
}
}

func TestAuthStatus_NotAuthenticated_ExitCode3(t *testing.T) {
keyring.MockInit()
setupTestConfig(t, `active_profile: default
Expand Down
42 changes: 42 additions & 0 deletions internal/cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ Use JC_PROFILE environment variable to select which JumpCloud org to use.`,
if !cmd.Flags().Changed("port") {
port = config.MCPSSEPort()
}
// Profile-role enforcement: a profile bound to a read-only OAuth
// client must not advertise mutation tools. Reject the start
// rather than silently coercing — operators who passed
// --read-only=false should see the error, not a phantom override.
coerced, warning, err := applyProfileRole(
config.ActiveProfile(),
config.IsReadOnlyProfile(),
cmd.Flags().Changed("read-only"),
readOnly,
)
if err != nil {
return err
}
readOnly = coerced
if warning != "" {
fmt.Fprintln(cmd.ErrOrStderr(), warning)
}
return runMcpServe(rateLimit, readOnly, transport, addr, port, corsOrigin, tlsCert, tlsKey, requireAuth)
},
}
Expand Down Expand Up @@ -178,6 +195,31 @@ func resolveSSEAddr(addr string, port int) string {
return addr
}

// applyProfileRole reconciles the --read-only flag with the active
// profile's role. Returns the effective read-only value, an optional
// warning message to surface to the operator, and an error if the flag
// and profile role conflict.
//
// Rules:
// - Profile is not read-only → pass through unchanged.
// - Profile is read-only and operator passed --read-only=false → error.
// - Profile is read-only and read-only is already true → no warning,
// no change (operator and profile agree).
// - Profile is read-only and read-only is false but flag wasn't
// explicitly set → coerce to true and emit a warning.
func applyProfileRole(activeProfile string, profileReadOnly, flagChanged, readOnly bool) (bool, string, error) {
if !profileReadOnly {
return readOnly, "", nil
}
if flagChanged && !readOnly {
return false, "", fmt.Errorf("active profile %q is read-only; --read-only=false is incompatible", activeProfile)
}
if readOnly {
return true, "", nil
}
return true, fmt.Sprintf("Profile %q is read-only — forcing --read-only and rejecting destructive tools.", activeProfile), nil
}

func runMcpServe(rateLimit int, readOnly bool, transport, addr string, port int, corsOrigin, tlsCert, tlsKey string, requireAuth bool) error {
server := mcp.NewServer(mcp.Options{
RateLimit: rateLimit,
Expand Down
72 changes: 72 additions & 0 deletions internal/cmd/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,75 @@ func TestMcpCmd_IncludesToolsSubcommand(t *testing.T) {
t.Error("expected mcp help to mention tools subcommand")
}
}

func TestApplyProfileRole_NoRolePassesThrough(t *testing.T) {
cases := []struct {
flagChanged bool
readOnly bool
}{
{flagChanged: false, readOnly: false},
{flagChanged: false, readOnly: true},
{flagChanged: true, readOnly: false},
{flagChanged: true, readOnly: true},
}
for _, c := range cases {
got, warn, err := applyProfileRole("default", false, c.flagChanged, c.readOnly)
if err != nil {
t.Errorf("unexpected error for %+v: %v", c, err)
}
if warn != "" {
t.Errorf("unexpected warning for %+v: %q", c, warn)
}
if got != c.readOnly {
t.Errorf("for %+v: got readOnly=%v, want %v", c, got, c.readOnly)
}
}
}

func TestApplyProfileRole_ReadOnlyProfileForcesTrue(t *testing.T) {
got, warn, err := applyProfileRole("reporting", true, false, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got {
t.Errorf("readOnly = false, want true (read-only profile, no flag)")
}
if !strings.Contains(warn, "reporting") {
t.Errorf("warning missing profile name: %q", warn)
}
if !strings.Contains(warn, "read-only") {
t.Errorf("warning missing read-only mention: %q", warn)
}
}

func TestApplyProfileRole_ReadOnlyProfileSilentWhenFlagAgrees(t *testing.T) {
got, warn, err := applyProfileRole("reporting", true, true, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !got {
t.Errorf("readOnly = false, want true")
}
if warn != "" {
t.Errorf("expected no warning when operator and profile agree, got %q", warn)
}
}

func TestApplyProfileRole_ReadOnlyProfileRejectsExplicitFalse(t *testing.T) {
got, warn, err := applyProfileRole("reporting", true, true, false)
if err == nil {
t.Fatal("expected error when --read-only=false is passed against a read-only profile")
}
if !strings.Contains(err.Error(), "reporting") {
t.Errorf("error missing profile name: %v", err)
}
if !strings.Contains(err.Error(), "incompatible") {
t.Errorf("error should call out the incompatibility: %v", err)
}
if got {
t.Errorf("on error, readOnly should not be coerced to true; got %v", got)
}
if warn != "" {
t.Errorf("on error, warning should be empty; got %q", warn)
}
}
33 changes: 33 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,39 @@ func AuthMethod() string {
return "api_key"
}

// ProfileRoleReadOnly is the role value that constrains a profile to
// read-only operations. When the active profile carries this role, the
// MCP server boots with ReadOnly=true regardless of the --read-only flag.
const ProfileRoleReadOnly = "read_only"

// ProfileRole returns the role assigned to the named profile. An empty
// string means the profile has no role and behaves normally. The only
// non-empty value currently understood is ProfileRoleReadOnly.
func ProfileRole(profile string) string {
if profile == "" {
profile = ActiveProfile()
}
return viper.GetString("profiles." + profile + ".auth_profile_role")
}

// IsReadOnlyProfile returns true if the active profile is bound to
// read-only operations. Used by `jc mcp serve` to refuse mutation tools
// even if the operator forgot the --read-only flag.
func IsReadOnlyProfile() bool {
return ProfileRole("") == ProfileRoleReadOnly
}

// SetProfileRole writes the role onto the named profile. Pass an empty
// role to clear it. Only ProfileRoleReadOnly (or "") is accepted today;
// unknown values return an error so the config doesn't carry typos that
// silently fail to enforce anything.
func SetProfileRole(profile, role string) error {
if role != "" && role != ProfileRoleReadOnly {
return fmt.Errorf("invalid profile role %q (must be %q or empty)", role, ProfileRoleReadOnly)
}
return SetProfileField(profile, "auth_profile_role", role)
}

// ClientID returns the OAuth 2.0 client ID for the active profile.
func ClientID() string {
profile := ActiveProfile()
Expand Down
Loading
Loading