From 243989d0e12b0ca38644bae7e7fe7ef1e41d1a10 Mon Sep 17 00:00:00 2001 From: Ali Falahi Date: Fri, 27 Mar 2026 15:40:42 -0600 Subject: [PATCH 1/3] feat: add virtual-entitlements create command Add `cone virtual-entitlements create` for creating manually-managed resource types, resources, and entitlements on ConductorOne apps. Supports both CLI flags and YAML file input. --- CLAUDE.md | 60 ++++++++ cmd/cone/main.go | 1 + cmd/cone/virtual_entitlements.go | 234 ++++++++++++++++++++++++++++++ pkg/client/client.go | 5 + pkg/client/virtual_entitlement.go | 190 ++++++++++++++++++++++++ 5 files changed, 490 insertions(+) create mode 100644 CLAUDE.md create mode 100644 cmd/cone/virtual_entitlements.go create mode 100644 pkg/client/virtual_entitlement.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0b198d1a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# 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/_/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..(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 diff --git a/cmd/cone/main.go b/cmd/cone/main.go index dc264b2e..f272d4c5 100644 --- a/cmd/cone/main.go +++ b/cmd/cone/main.go @@ -80,6 +80,7 @@ func runCli(ctx context.Context) int { cliCmd.AddCommand(hasCmd()) cliCmd.AddCommand(tokenCmd()) cliCmd.AddCommand(decryptCredentialCmd()) + cliCmd.AddCommand(virtualEntitlementsCmd()) err = cliCmd.ExecuteContext(ctx) if err != nil { diff --git a/cmd/cone/virtual_entitlements.go b/cmd/cone/virtual_entitlements.go new file mode 100644 index 00000000..5ef335f5 --- /dev/null +++ b/cmd/cone/virtual_entitlements.go @@ -0,0 +1,234 @@ +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\-_.]`) + +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() +} diff --git a/pkg/client/client.go b/pkg/client/client.go index 567ef1b0..99523396 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -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) } func (c *client) BaseURL() string { diff --git a/pkg/client/virtual_entitlement.go b/pkg/client/virtual_entitlement.go new file mode 100644 index 00000000..84bcd632 --- /dev/null +++ b/pkg/client/virtual_entitlement.go @@ -0,0 +1,190 @@ +package client + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/conductorone/conductorone-sdk-go/pkg/models/operations" + "github.com/conductorone/conductorone-sdk-go/pkg/models/shared" +) + +var resourceTypeMap = map[string]shared.ResourceType{ + "ROLE": shared.ResourceTypeRole, + "GROUP": shared.ResourceTypeGroup, + "LICENSE": shared.ResourceTypeLicense, + "PROJECT": shared.ResourceTypeProject, + "CATALOG": shared.ResourceTypeCatalog, + "CUSTOM": shared.ResourceTypeCustom, + "VAULT": shared.ResourceTypeVault, + "PROFILE_TYPE": shared.ResourceTypeProfileType, +} + +// ResolveResourceType maps a user-provided type string to (ResourceType enum, display name). +// Known enum names map directly. Anything else uses CUSTOM with the user's string as the display name. +func ResolveResourceType(typeStr string) (shared.ResourceType, string) { + normalized := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(typeStr, "-", "_"), " ", "_")) + if rt, ok := resourceTypeMap[normalized]; ok { + return rt, strings.ToUpper(normalized[:1]) + strings.ToLower(normalized[1:]) + } + return shared.ResourceTypeCustom, typeStr +} + +func (c *client) CreateManuallyManagedResourceType( + ctx context.Context, + appID string, + resourceType shared.ResourceType, + displayName string, +) (*shared.AppResourceType, error) { + resp, err := c.sdk.AppResourceType.CreateManuallyManagedResourceType( + ctx, + operations.C1APIAppV1AppResourceTypeServiceCreateManuallyManagedResourceTypeRequest{ + AppID: appID, + CreateManuallyManagedResourceTypeRequest: &shared.CreateManuallyManagedResourceTypeRequest{ + DisplayName: displayName, + ResourceType: resourceType, + }, + }, + ) + if err != nil { + return nil, err + } + if err := NewHTTPError(resp.RawResponse); err != nil { + return nil, err + } + + rt := resp.CreateManuallyManagedResourceTypeResponse.GetAppResourceType() + if rt == nil { + return nil, errors.New("create-resource-type: response is nil") + } + return rt, nil +} + +func (c *client) CreateManuallyManagedResource( + ctx context.Context, + appID string, + resourceTypeID string, + displayName string, + description string, +) (*shared.AppResource, error) { + req := shared.CreateManuallyManagedAppResourceRequest{ + DisplayName: displayName, + } + if description != "" { + req.Description = &description + } + + resp, err := c.sdk.AppResource.CreateManuallyManagedAppResource( + ctx, + operations.C1APIAppV1AppResourceServiceCreateManuallyManagedAppResourceRequest{ + AppID: appID, + AppResourceTypeID: resourceTypeID, + CreateManuallyManagedAppResourceRequest: &req, + }, + ) + if err != nil { + return nil, err + } + if err := NewHTTPError(resp.RawResponse); err != nil { + return nil, err + } + + resource := resp.CreateManuallyManagedAppResourceResponse.GetAppResource() + if resource == nil { + return nil, errors.New("create-resource: response is nil") + } + return resource, nil +} + +func (c *client) CreateAppEntitlement( + ctx context.Context, + appID string, + resourceTypeID string, + resourceID string, + displayName string, + slug string, +) (*shared.AppEntitlement, error) { + resp, err := c.sdk.AppEntitlements.Create( + ctx, + operations.C1APIAppV1AppEntitlementsCreateRequest{ + AppID: appID, + CreateAppEntitlementRequest: &shared.CreateAppEntitlementRequest{ + DisplayName: displayName, + AppResourceTypeID: &resourceTypeID, + AppResourceID: &resourceID, + Slug: &slug, + }, + }, + ) + if err != nil { + return nil, err + } + if err := NewHTTPError(resp.RawResponse); err != nil { + return nil, err + } + + view := resp.CreateAppEntitlementResponse.GetAppEntitlementView() + if view == nil || view.AppEntitlement == nil { + return nil, errors.New("create-entitlement: response is nil") + } + return view.AppEntitlement, nil +} + +func (c *client) SearchApps(ctx context.Context, query string) ([]shared.App, error) { + pageSize := 50 + resp, err := c.sdk.AppSearch.Search(ctx, &shared.SearchAppsRequest{ + DisplayName: stringPtr(query), + PageSize: &pageSize, + }) + if err != nil { + return nil, err + } + if err := NewHTTPError(resp.RawResponse); err != nil { + return nil, err + } + return resp.SearchAppsResponse.GetList(), nil +} + +// ResolveAppByNameOrID looks up an app by display name or ID. +// If the input looks like a KSUID (27 alphanumeric chars), it fetches directly by ID. +// Otherwise, it searches by display name and returns an exact or unique match. +func (c *client) ResolveAppByNameOrID(ctx context.Context, appIDOrName string) (*shared.App, error) { + if len(appIDOrName) == 27 && isAlphanumeric(appIDOrName) { + return c.GetApp(ctx, appIDOrName) + } + + apps, err := c.SearchApps(ctx, appIDOrName) + if err != nil { + return nil, err + } + if len(apps) == 0 { + return nil, fmt.Errorf("no app found matching %q", appIDOrName) + } + + // Exact match first. + for i := range apps { + if strings.EqualFold(StringFromPtr(apps[i].DisplayName), appIDOrName) { + return &apps[i], nil + } + } + + if len(apps) == 1 { + return &apps[0], nil + } + + var names []string + for _, app := range apps { + names = append(names, fmt.Sprintf(" %s %s", StringFromPtr(app.ID), StringFromPtr(app.DisplayName))) + } + return nil, fmt.Errorf("multiple apps match %q, please use the app ID directly:\n%s", appIDOrName, strings.Join(names, "\n")) +} + +func isAlphanumeric(s string) bool { + for _, r := range s { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return false + } + } + return true +} From 467b72891f5f70389006a232d0a4032d4a1ad85d Mon Sep 17 00:00:00 2001 From: Ali Falahi Date: Fri, 27 Mar 2026 15:52:07 -0600 Subject: [PATCH 2/3] chore: fix all lint issues Fix gosec false positives with nolint annotations, lowercase error strings per Go convention, and simplify embedded field selectors. --- CLAUDE.md | 1 + cmd/cone/get_drop_task.go | 4 ++-- pkg/client/entitlement.go | 6 +++--- pkg/client/token_source.go | 1 + pkg/client/virtual_entitlement.go | 6 +++--- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0b198d1a..6b8df3e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,7 @@ Uses vendored dependencies (`vendor/`). After modifying `go.mod`, run `go mod ti ## Linting Rules Key non-obvious rules from `.golangci.yml`: + - Line length limit: 200 characters - No naked returns (any function length) - No named returns diff --git a/cmd/cone/get_drop_task.go b/cmd/cone/get_drop_task.go index c51c0c5f..4c4b7aa3 100644 --- a/cmd/cone/get_drop_task.go +++ b/cmd/cone/get_drop_task.go @@ -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" 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{ diff --git a/pkg/client/entitlement.go b/pkg/client/entitlement.go index d57a7246..057ba0fc 100644 --- a/pkg/client/entitlement.go +++ b/pkg/client/entitlement.go @@ -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, @@ -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), }) } diff --git a/pkg/client/token_source.go b/pkg/client/token_source.go index 776e41b8..7c166897 100644 --- a/pkg/client/token_source.go +++ b/pkg/client/token_source.go @@ -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 } diff --git a/pkg/client/virtual_entitlement.go b/pkg/client/virtual_entitlement.go index 84bcd632..c3114a69 100644 --- a/pkg/client/virtual_entitlement.go +++ b/pkg/client/virtual_entitlement.go @@ -78,8 +78,8 @@ func (c *client) CreateManuallyManagedResource( resp, err := c.sdk.AppResource.CreateManuallyManagedAppResource( ctx, operations.C1APIAppV1AppResourceServiceCreateManuallyManagedAppResourceRequest{ - AppID: appID, - AppResourceTypeID: resourceTypeID, + AppID: appID, + AppResourceTypeID: resourceTypeID, CreateManuallyManagedAppResourceRequest: &req, }, ) @@ -182,7 +182,7 @@ func (c *client) ResolveAppByNameOrID(ctx context.Context, appIDOrName string) ( func isAlphanumeric(s string) bool { for _, r := range s { - if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') { return false } } From 0fa842f6724f337cc3c961161556c9cbe5ca95dd Mon Sep 17 00:00:00 2001 From: Robert Chiniquy Date: Sun, 29 Mar 2026 09:57:08 -0700 Subject: [PATCH 3/3] Use canonical display names for resource types ResolveResourceType was title-casing the normalized enum key, which produced incorrect display names for multi-word types (e.g. "Profile_type" instead of "Profile Type"). Use a dedicated map of canonical display names matching the C1 server conventions. Also document the slug collision behavior in makeSlug. --- cmd/cone/virtual_entitlements.go | 4 ++++ pkg/client/virtual_entitlement.go | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cmd/cone/virtual_entitlements.go b/cmd/cone/virtual_entitlements.go index 5ef335f5..b009343d 100644 --- a/cmd/cone/virtual_entitlements.go +++ b/cmd/cone/virtual_entitlements.go @@ -70,6 +70,10 @@ YAML format: 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, " ", "-") diff --git a/pkg/client/virtual_entitlement.go b/pkg/client/virtual_entitlement.go index c3114a69..ee574b06 100644 --- a/pkg/client/virtual_entitlement.go +++ b/pkg/client/virtual_entitlement.go @@ -21,12 +21,25 @@ var resourceTypeMap = map[string]shared.ResourceType{ "PROFILE_TYPE": shared.ResourceTypeProfileType, } +// resourceTypeDisplayNames maps enum keys to canonical display names matching +// the C1 server's conventions (see pkg/connector/manual/constants.go). +var resourceTypeDisplayNames = map[string]string{ + "ROLE": "Role", + "GROUP": "Group", + "LICENSE": "License", + "PROJECT": "Project", + "CATALOG": "Access Profile", + "CUSTOM": "Custom", + "VAULT": "Vault", + "PROFILE_TYPE": "Profile Type", +} + // ResolveResourceType maps a user-provided type string to (ResourceType enum, display name). // Known enum names map directly. Anything else uses CUSTOM with the user's string as the display name. func ResolveResourceType(typeStr string) (shared.ResourceType, string) { normalized := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(typeStr, "-", "_"), " ", "_")) if rt, ok := resourceTypeMap[normalized]; ok { - return rt, strings.ToUpper(normalized[:1]) + strings.ToLower(normalized[1:]) + return rt, resourceTypeDisplayNames[normalized] } return shared.ResourceTypeCustom, typeStr }