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
61 changes: 61 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

Cone is a CLI tool for the ConductorOne platform, written in Go. It manages access to entitlements (request, approve, deny, revoke access) via the ConductorOne API.

## Build & Development Commands

```bash
make build # Build binary → dist/<OS>_<ARCH>/cone
make lint # Run golangci-lint (strict config in .golangci.yml, ~30 linters)
go test ./... # Run all tests
go test -v -run TestName ./pkg/client/ # Run a single test
make update-deps # Update all Go dependencies and re-vendor
```

Uses vendored dependencies (`vendor/`). After modifying `go.mod`, run `go mod tidy -v && go mod vendor`.

## Architecture

### Entry Point & Commands (`cmd/cone/`)

- `main.go` — Cobra root command setup, signal handling, registers all subcommands
- `cmd.go` — `cmdContext()` creates authenticated `C1Client` + viper config for each command. Auth priority: access token env var → OIDC token exchange → client credentials
- Each command file (e.g., `task.go`, `search_entitlements.go`) returns a `*cobra.Command` and calls `cmdContext()` to get the client

### Core Packages (`pkg/`)

- **`client/`** — Wraps `conductorone-sdk-go`. The `C1Client` interface defines all API operations. Auth uses JWT bearer assertion (Ed25519 signed) via `token_source.go`, with RFC 8693 token exchange in `token_exchange.go`. Client ID format: `name@host/suffix` (host is parsed to determine API endpoint).
- **`output/`** — Pluggable output formatting. `Manager` interface with table/JSON implementations. Data types implement `TablePrint` (`Header() []string`, `Rows() [][]string`) for table output, and optionally `WideTablePrint` (`WideHeader()`, `WideRows()`) for wide mode. JSON output serializes the struct directly.
- **`uhttp/`** — HTTP client factory with OAuth2 token source, debug logging, custom transport.
- **`logging/`** — Singleton zap logger initialized once at startup.

### Adding a New Command

1. Create file in `cmd/cone/`
2. Return a `*cobra.Command` that calls `cmdContext(cmd)` to get `(ctx, client, viper, error)`
3. Use `output.NewManager(ctx, v)` to format output
4. Register in `main.go` via `cliCmd.AddCommand()`

### Adding a New Client Method

1. Add the method signature to the `C1Client` interface in `pkg/client/client.go`
2. Implement on `*client` in the appropriate file under `pkg/client/`
3. Use `c.sdk.<Service>.<Method>(ctx, operationsRequest)` to call the SDK
4. Check `NewHTTPError(resp.RawResponse)` for HTTP-level errors

## Linting Rules

Key non-obvious rules from `.golangci.yml`:

- Line length limit: 200 characters
- No naked returns (any function length)
- No named returns
- Comments must end in a period (except TODOs)
- Variable naming: use `ID`, `URL`, `HTTP`, `API` (not `Id`, `Url`, etc.)
- No `init()` functions
- All errors must be checked (exceptions: `fmt.Printf/Println`, `fmt.Fprintf/Fprintln`)
- `goimports` for import formatting
4 changes: 2 additions & 2 deletions cmd/cone/get_drop_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import (
const durationErrorMessage = "grant duration must be less than or equal to max provision time"
const durationInputTip = "We accept a sequence of decimal numbers, each with optional fraction and a unit suffix," +
"such as \"12h\", \"1w2d\" or \"2h45m\". Valid units are (m)inutes, (h)ours, (d)ays, (w)eeks."
const justificationWarningMessage = "Please provide a justification when requesting access to an entitlement."
const justificationWarningMessage = "please provide a justification when requesting access to an entitlement"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting

const justificationInputTip = "You can add a justification using -j or --justification"
const appUserMultipleUsersWarningMessage = "This app has multiple users. Please select any one. "
const appUserMultipleUsersWarningMessage = "this app has multiple users, please select any one"

func getCmd() *cobra.Command {
cmd := &cobra.Command{
Expand Down
1 change: 1 addition & 0 deletions cmd/cone/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ func runCli(ctx context.Context) int {
cliCmd.AddCommand(hasCmd())
cliCmd.AddCommand(tokenCmd())
cliCmd.AddCommand(decryptCredentialCmd())
cliCmd.AddCommand(virtualEntitlementsCmd())
cliCmd.AddCommand(generateAliasCmd())

err = cliCmd.ExecuteContext(ctx)
Expand Down
238 changes: 238 additions & 0 deletions cmd/cone/virtual_entitlements.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
package main

import (
"fmt"
"os"
"regexp"
"strings"

"github.com/spf13/cobra"
"gopkg.in/yaml.v3"

"github.com/conductorone/cone/pkg/client"
"github.com/conductorone/cone/pkg/output"
)

type virtualEntitlementYAML struct {
Resources []virtualResourceYAML `yaml:"resources"`
}

type virtualResourceYAML struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Description string `yaml:"description"`
Entitlements []string `yaml:"entitlements"`
}

func virtualEntitlementsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "virtual-entitlements",
Short: "Manage virtual (manually-managed) entitlements.",
}
cmd.AddCommand(virtualEntitlementsCreateCmd())
return cmd
}

func virtualEntitlementsCreateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: "Create virtual resource types, resources, and entitlements on an app.",
Long: `Create virtual (manually-managed) entitlements on a ConductorOne app.

You can specify resources and entitlements via CLI flags or a YAML file.

CLI example:
cone virtual-entitlements create --app "My App" --resource "My Group" --type GROUP --entitlements "Member" --entitlements "Admin"

YAML file example:
cone virtual-entitlements create --app "My App" --from-file entitlements.yaml

YAML format:
resources:
- name: "My Group"
type: GROUP
description: "optional"
entitlements:
- "Member"
- "Admin"`,
RunE: virtualEntitlementsCreateRun,
}
cmd.Flags().String("app", "", "App ID or display name (required).")
cmd.Flags().String("resource", "", "Resource display name.")
cmd.Flags().StringP("type", "t", "CUSTOM", "Resource type: ROLE, GROUP, LICENSE, PROJECT, CATALOG, CUSTOM, VAULT, PROFILE_TYPE.")
cmd.Flags().StringSlice("entitlements", nil, "Entitlement names (repeatable).")
cmd.Flags().StringP("from-file", "f", "", "YAML file with resource/entitlement definitions.")
if err := cmd.MarkFlagRequired("app"); err != nil {
panic(err)
}
return cmd
}

var slugRegexp = regexp.MustCompile(`[^a-z0-9\-_.]`)

// makeSlug produces a URL-safe slug from a display name. Non-alphanumeric characters
// are replaced with hyphens, so distinct names like "foo@bar" and "foo#bar" will
// collide to the same slug. This is acceptable because the C1 API upserts entitlements
// on (app, resource_type, resource) — slug is not part of the uniqueness key.
func makeSlug(name string) string {
slug := strings.ToLower(name)
slug = strings.ReplaceAll(slug, " ", "-")
slug = slugRegexp.ReplaceAllString(slug, "-")
slug = strings.Trim(slug, "-")
return slug
}

func virtualEntitlementsCreateRun(cmd *cobra.Command, args []string) error {
ctx, c, v, err := cmdContext(cmd)
if err != nil {
return err
}

appIDOrName, err := cmd.Flags().GetString("app")
if err != nil {
return err
}

app, err := c.ResolveAppByNameOrID(ctx, appIDOrName)
if err != nil {
return err
}

appID := client.StringFromPtr(app.ID)
appName := client.StringFromPtr(app.DisplayName)
fmt.Fprintf(os.Stderr, "Using app: %s (%s)\n", appName, appID)

fromFile, err := cmd.Flags().GetString("from-file")
if err != nil {
return err
}

var resources []virtualResourceYAML
if fromFile != "" {
data, err := os.ReadFile(fromFile)
if err != nil {
return fmt.Errorf("reading file %s: %w", fromFile, err)
}
var spec virtualEntitlementYAML
if err := yaml.Unmarshal(data, &spec); err != nil {
return fmt.Errorf("parsing YAML file %s: %w", fromFile, err)
}
if len(spec.Resources) == 0 {
return fmt.Errorf("no resources found in %s", fromFile)
}
resources = spec.Resources
} else {
resourceName, err := cmd.Flags().GetString("resource")
if err != nil {
return err
}
if resourceName == "" {
return fmt.Errorf("--resource is required when not using --from-file")
}

entitlementNames, err := cmd.Flags().GetStringSlice("entitlements")
if err != nil {
return err
}
if len(entitlementNames) == 0 {
return fmt.Errorf("--entitlements is required when not using --from-file")
}

typeName, err := cmd.Flags().GetString("type")
if err != nil {
return err
}

resources = []virtualResourceYAML{
{
Name: resourceName,
Type: typeName,
Entitlements: entitlementNames,
},
}
}

resp := &VirtualEntitlementsResponse{}

for _, res := range resources {
if res.Type == "" {
res.Type = "CUSTOM"
}
resourceType, typeDisplayName := client.ResolveResourceType(res.Type)

fmt.Fprintf(os.Stderr, "\nProcessing resource: %s (type: %s)\n", res.Name, typeDisplayName)

rt, err := c.CreateManuallyManagedResourceType(ctx, appID, resourceType, typeDisplayName)
if err != nil {
return fmt.Errorf("creating resource type %q: %w", typeDisplayName, err)
}
rtID := client.StringFromPtr(rt.ID)
fmt.Fprintf(os.Stderr, " Created resource type: %s (%s)\n", typeDisplayName, rtID)

resource, err := c.CreateManuallyManagedResource(ctx, appID, rtID, res.Name, res.Description)
if err != nil {
return fmt.Errorf("creating resource %q: %w", res.Name, err)
}
resourceID := client.StringFromPtr(resource.ID)
fmt.Fprintf(os.Stderr, " Created resource: %s (%s)\n", res.Name, resourceID)

for _, entName := range res.Entitlements {
ent, err := c.CreateAppEntitlement(ctx, appID, rtID, resourceID, entName, makeSlug(entName))
if err != nil {
return fmt.Errorf("creating entitlement %q: %w", entName, err)
}
entID := client.StringFromPtr(ent.ID)
fmt.Fprintf(os.Stderr, " Created entitlement: %s (%s)\n", entName, entID)

resp.Entitlements = append(resp.Entitlements, virtualEntitlementRow{
AppName: appName,
ResourceTypeName: typeDisplayName,
ResourceName: res.Name,
EntitlementName: entName,
EntitlementID: entID,
})
}
}

outputManager := output.NewManager(ctx, v)
return outputManager.Output(ctx, resp)
}

type virtualEntitlementRow struct {
AppName string `json:"appName"`
ResourceTypeName string `json:"resourceTypeName"`
ResourceName string `json:"resourceName"`
EntitlementName string `json:"entitlementName"`
EntitlementID string `json:"entitlementId"`
}

// VirtualEntitlementsResponse implements the output interfaces for table/JSON rendering.
type VirtualEntitlementsResponse struct {
Entitlements []virtualEntitlementRow `json:"entitlements"`
}

func (r *VirtualEntitlementsResponse) Header() []string {
return []string{"App", "Resource Type", "Resource", "Entitlement", "Entitlement ID"}
}

func (r *VirtualEntitlementsResponse) Rows() [][]string {
rows := make([][]string, 0, len(r.Entitlements))
for _, e := range r.Entitlements {
rows = append(rows, []string{
e.AppName,
e.ResourceTypeName,
e.ResourceName,
e.EntitlementName,
e.EntitlementID,
})
}
return rows
}

func (r *VirtualEntitlementsResponse) WideHeader() []string {
return r.Header()
}

func (r *VirtualEntitlementsResponse) WideRows() [][]string {
return r.Rows()
}
5 changes: 5 additions & 0 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ type C1Client interface {
ListAppUserCredentials(ctx context.Context, appID string, appUserID string) ([]shared.AppUserCredential, error)
ListPolicies(ctx context.Context) ([]shared.Policy, error)
ListEntitlements(ctx context.Context, appId string) ([]shared.AppEntitlement, error)
SearchApps(ctx context.Context, query string) ([]shared.App, error)
ResolveAppByNameOrID(ctx context.Context, appIDOrName string) (*shared.App, error)
CreateManuallyManagedResourceType(ctx context.Context, appID string, resourceType shared.ResourceType, displayName string) (*shared.AppResourceType, error)
CreateManuallyManagedResource(ctx context.Context, appID string, resourceTypeID string, displayName string, description string) (*shared.AppResource, error)
CreateAppEntitlement(ctx context.Context, appID string, resourceTypeID string, resourceID string, displayName string, slug string) (*shared.AppEntitlement, error)
UpdateEntitlement(ctx context.Context, appID, entitlementID string, req *shared.UpdateAppEntitlementRequest) error
}

Expand Down
6 changes: 3 additions & 3 deletions pkg/client/entitlement.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func (e *ExpandableEntitlementWithBindings) GetPaths() []PathDetails {
if e == nil {
return nil
}
view := *e.AppEntitlementWithUserBindings.AppEntitlementView
view := *e.AppEntitlementView
return []PathDetails{
{
Name: ExpandedApp,
Expand Down Expand Up @@ -158,8 +158,8 @@ func (c *client) SearchEntitlements(ctx context.Context, filter *SearchEntitleme
rv := make([]*EntitlementWithBindings, 0, len(list))
for _, v := range expandableList {
rv = append(rv, &EntitlementWithBindings{
Entitlement: AppEntitlement(*v.AppEntitlementWithUserBindings.AppEntitlementView.AppEntitlement),
Bindings: v.AppEntitlementWithUserBindings.AppEntitlementUserBindings,
Entitlement: AppEntitlement(*v.AppEntitlementView.AppEntitlement),
Bindings: v.AppEntitlementUserBindings,
expanded: PopulateExpandedMap(v.ExpandedMap, expanded),
})
}
Expand Down
1 change: 1 addition & 0 deletions pkg/client/token_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ func (c *c1TokenSource) Token() (*oauth2.Token, error) {
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)

if err != nil {
return nil, err
}
Expand Down
Loading
Loading