diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3d89528a..4c3cd213 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,9 +11,9 @@ jobs: - name: Checkout code uses: actions/checkout@v3 - name: Run linters - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v7 with: - version: latest + version: v2.8.0 args: --timeout=3m go-test: strategy: diff --git a/.golangci.yml b/.golangci.yml index 4ab47e29..ace0a48a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,103 +1,130 @@ -linters-settings: - exhaustive: - default-signifies-exhaustive: true - - gocritic: - # The list of supported checkers can be find in https://go-critic.github.io/overview. - settings: - underef: - # Whether to skip (*x).method() calls where x is a pointer receiver. - skipRecvDeref: false - - govet: - enable-all: true - disable: - - fieldalignment # too strict - - shadow # complains too much about shadowing errors. All research points to this being fine. - - nakedret: - max-func-lines: 0 - - nolintlint: - allow-no-explanation: [ forbidigo, tracecheck, gomnd, gochecknoinits, makezero ] - require-explanation: true - require-specific: true - - revive: - ignore-generated-header: true - severity: error - rules: - - name: atomic - - name: line-length-limit - arguments: [ 200 ] - # These are functions that we use without checking the errors often. Most of these can't return an error even - # though they implement an interface that can. - - name: unhandled-error - arguments: - - fmt.Printf - - fmt.Println - - fmt.Fprintf - - fmt.Fprintln - - os.Stderr.Sync - - sb.WriteString - - buf.WriteString - - hasher.Write - - os.Setenv - - os.RemoveAll - - name: var-naming - arguments: [["ID", "URL", "HTTP", "API"], []] - - tenv: - all: true - - varcheck: - exported-fields: false # this appears to improperly detect exported variables as unused when they are used from a package with the same name - - +version: "2" linters: - disable-all: true + default: none enable: - - errcheck # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases - - gosimple # Linter for Go source code that specializes in simplifying a code - - govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string - - ineffassign # Detects when assignments to existing variables are not used - - staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks - - typecheck # Like the front-end of a Go compiler, parses and type-checks Go code - - unused # Checks Go code for unused constants, variables, functions and types - - asasalint # Check for pass []any as any in variadic func(...any) - - asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers - - bidichk # Checks for dangerous unicode character sequences - - bodyclose # checks whether HTTP response body is closed successfully - - durationcheck # check for two durations multiplied together - - errorlint # errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13. - - exhaustive # check exhaustiveness of enum switch statements - - forbidigo # Forbids identifiers - - gochecknoinits # Checks that no init functions are present in Go code - - goconst # Finds repeated strings that could be replaced by a constant - - gocritic # Provides diagnostics that check for bugs, performance and style issues. - - godot # Check if comments end in a period - - goimports # In addition to fixing imports, goimports also formats your code in the same style as gofmt. - - gomoddirectives # Manage the use of 'replace', 'retract', and 'excludes' directives in go.mod. - - goprintffuncname # Checks that printf-like functions are named with f at the end - - gosec # Inspects source code for security problems - - nakedret # Finds naked returns in functions greater than a specified function length - - nilerr # Finds the code that returns nil even if it checks that the error is not nil. - - noctx # noctx finds sending http request without context.Context - - nolintlint # Reports ill-formed or insufficient nolint directives - - nonamedreturns # Reports all named returns - - nosprintfhostport # Checks for misuse of Sprintf to construct a host with port in a URL. - - predeclared # find code that shadows one of Go's predeclared identifiers - - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint. - - tenv # tenv is analyzer that detects using os.Setenv instead of t.Setenv since Go1.17 - - tparallel # tparallel detects inappropriate usage of t.Parallel() method in your Go test codes - - unconvert # Remove unnecessary type conversions - - usestdlibvars # detect the possibility to use variables/constants from the Go standard library - - whitespace # Tool for detection of leading and trailing whitespace - + - asasalint + - asciicheck + - bidichk + - bodyclose + - durationcheck + - errcheck + - errorlint + - exhaustive + - forbidigo + - gochecknoinits + - goconst + - gocritic + - godot + - gomoddirectives + - goprintffuncname + - gosec + - govet + - ineffassign + - nakedret + - nilerr + - noctx + - nolintlint + - nonamedreturns + - nosprintfhostport + - predeclared + - revive + - staticcheck + - tparallel + - unconvert + - unused + - usestdlibvars + - usetesting + - whitespace + settings: + exhaustive: + default-signifies-exhaustive: true + gocritic: + settings: + underef: + skipRecvDeref: false + govet: + disable: + - fieldalignment + - shadow + enable-all: true + nakedret: + max-func-lines: 0 + nolintlint: + require-explanation: true + require-specific: true + allow-no-explanation: + - forbidigo + - tracecheck + - gomnd + - gochecknoinits + - makezero + revive: + severity: error + rules: + - name: atomic + - name: line-length-limit + arguments: + - 200 + - name: unhandled-error + arguments: + - fmt.Printf + - fmt.Println + - fmt.Fprintf + - fmt.Fprintln + - os.Stderr.Sync + - sb.WriteString + - buf.WriteString + - hasher.Write + - os.Setenv + - os.RemoveAll + - name: var-naming + arguments: + - - ID + - URL + - HTTP + - API + - [] + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - godot + source: (TODO) + - linters: + - forbidigo + path: cmd/ + - linters: + - forbidigo + path: pkg/mcpclient/tools\.go + - linters: + - forbidigo + path: pkg/prompt/ + - linters: + - forbidigo + path: pkg/mcpclient/mock/ + - linters: + - nilerr + path: pkg/mcpclient/tools\.go + - linters: + - nilerr + path: cmd/cone/connector_dev\.go + paths: + - third_party$ + - builtin$ + - examples$ issues: max-same-issues: 50 - - exclude-rules: - # Don't require TODO comments to end in a period - - source: "(TODO)" - linters: [ godot ] +formatters: + enable: + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/DEMO.md b/DEMO.md new file mode 100644 index 00000000..b58d1c90 --- /dev/null +++ b/DEMO.md @@ -0,0 +1,612 @@ +# Cone Registry CLI Demo + +Demonstrate the `cone registry` commands for browsing, downloading, and publishing connectors. + +--- + +## Connector Run Modes + +ConductorOne connectors can run in different deployment modes: + +| Mode | Description | Use Case | +|------|-------------|----------| +| **Managed (Lambda)** | Hosted by ConductorOne in AWS Lambda | Default for cloud-hosted connectors; zero infrastructure to manage | +| **Self-Hosted (Local)** | Downloaded binary running in your infrastructure | Air-gapped environments, custom networks, on-prem systems | +| **Vendored** | Built into ConductorOne platform | Legacy connectors, special integrations | + +### When You Download a Connector + +Using `cone registry download` implies **self-hosted mode**: + +- You download the connector binary to your infrastructure +- You run it locally (or in your cloud, Kubernetes, etc.) +- You configure it to sync back to ConductorOne +- You manage updates, scaling, and availability + +This is different from **managed connectors** which: +- Run automatically in ConductorOne's infrastructure +- Are configured entirely through the UI/API +- Update automatically when new versions are published +- Require no infrastructure management + +### Choosing a Run Mode + +| Scenario | Recommended Mode | +|----------|------------------| +| Standard SaaS integrations (Okta, Google, etc.) | Managed | +| On-premises systems (Active Directory, databases) | Self-Hosted | +| Air-gapped or regulated environments | Self-Hosted | +| Custom/internal applications | Self-Hosted | +| Testing connector changes | Self-Hosted | + +--- + +## Assumptions & Prerequisites + +### For Production Use + +| Requirement | Description | +|-------------|-------------| +| ConductorOne Tenant | Active tenant at `your-tenant.conductor.one` | +| Okta SSO (Optional) | If using Okta for SSO, configure OIDC application | +| Publisher Role | User must have publisher scope assigned in ConductorOne | +| Admin Role | Admin commands require admin scope in ConductorOne | + +### Authentication Flow + +The `cone login` command uses OAuth 2.0 with PKCE: + +1. Opens browser to ConductorOne login page +2. User authenticates (directly or via SSO like Okta) +3. ConductorOne issues JWT with user's scopes +4. Token stored in `~/.conductorone/config.yaml` +5. Registry validates JWT against ConductorOne's JWKS endpoint + +**JWT Claims Used by Registry:** + +| Claim | Purpose | +|-------|---------| +| `iss` | Must match ConductorOne tenant URL | +| `aud` | Must match registry's configured audience | +| `sub` | User identifier for audit logging | +| `org` | Organization for resource scoping | +| `c1scp` | ConductorOne scope IDs (matched against publisher/admin roles) | + +### ConductorOne Role Configuration + +To grant registry access, assign these scopes in ConductorOne: + +| Permission Level | Required Scope | +|------------------|----------------| +| Publisher | Scope ID configured in registry's `roles.publisher` | +| Admin | Scope ID configured in registry's `roles.admin` | + +### For Local Development + +| Requirement | Description | +|-------------|-------------| +| Docker | For LocalStack (DynamoDB + S3) | +| Go 1.22+ | For building cone and registry | +| cosign (Optional) | For signature verification | + +--- + +## Part 1: Browse & Download (No Auth Required) + +These commands work without authentication against the public registry. + +### List All Connectors + +```bash +# List all available connectors (currently 51 from dist.conductorone.com) +cone registry list + +# Output as JSON for scripting +cone registry list --output json +``` + +> **Note**: The description overlay contains 167 entries (for future connectors), but dist.conductorone.com currently publishes 51 connectors. 46 of these have matching descriptions. + +### Show Connector Details + +```bash +# Show connector metadata (includes description from overlay) +cone registry show ConductorOne/baton-okta + +# Show a specific version +cone registry show ConductorOne/baton-okta v0.1.0 + +# Show available platforms for a version +cone registry show ConductorOne/baton-okta v0.1.0 --platforms +``` + +### List Versions + +```bash +# List all versions of a connector +cone registry versions ConductorOne/baton-okta +``` + +### Download a Connector + +```bash +# Download stable version for your platform +cone registry download ConductorOne/baton-okta + +# Download specific version +cone registry download ConductorOne/baton-okta v0.1.0 + +# Download for a different platform +cone registry download ConductorOne/baton-okta --platform linux-amd64 + +# Download to specific directory +cone registry download ConductorOne/baton-okta --output ./bin/ + +# Skip verification (not recommended) +cone registry download ConductorOne/baton-okta --skip-verify +``` + +--- + +## Part 2: Authentication + +Publisher and admin commands require ConductorOne authentication. + +### Login to ConductorOne + +```bash +# Login (opens browser for OAuth) +cone login your-tenant.conductor.one + +# Verify you're logged in +cone whoami +``` + +### Using Multiple Profiles + +```bash +# Login with named profiles +cone login prod-tenant.conductor.one --profile prod +cone login dev-tenant.conductor.one --profile dev + +# Use a specific profile +cone registry status --profile prod +``` + +--- + +## Part 3: Signing Keys (Publisher) + +Manage cryptographic keys for signing connector releases. + +### Generate a Key Pair + +```bash +# Generate ECDSA P-256 key pair (cosign-compatible) +cone registry keys generate release-2024 + +# Generates: +# release-2024.key (private - keep secret!) +# release-2024.pub (public - register with registry) +``` + +### Register Your Public Key + +```bash +# Register the public key with your organization +cone registry keys add ConductorOne \ + --name "Release Signing Key 2024" \ + --type cosign \ + --key-file release-2024.pub +``` + +### List Your Organization's Keys + +```bash +cone registry keys list ConductorOne +``` + +### Show Key Details + +```bash +cone registry keys show ConductorOne +``` + +--- + +## Part 4: Publishing (Publisher) + +Publish new connector versions to the registry. + +### Build Your Binaries + +```bash +# Build for multiple platforms +GOOS=linux GOARCH=amd64 go build -o dist/baton-myapp-linux-amd64 . +GOOS=linux GOARCH=arm64 go build -o dist/baton-myapp-linux-arm64 . +GOOS=darwin GOARCH=amd64 go build -o dist/baton-myapp-darwin-amd64 . +GOOS=darwin GOARCH=arm64 go build -o dist/baton-myapp-darwin-arm64 . +GOOS=windows GOARCH=amd64 go build -o dist/baton-myapp-windows-amd64.exe . +``` + +### Sign Your Binaries (Optional but Recommended) + +```bash +# Sign each binary with cosign +for binary in dist/baton-myapp-*; do + cosign sign-blob --key release-2024.key \ + --output-signature "${binary}.sig" \ + "$binary" +done +``` + +### Preview What Will Be Published + +```bash +# Dry run - shows what would be uploaded +cone registry publish MyOrg/baton-myapp v1.0.0 \ + --binary-dir ./dist/ \ + --dry-run +``` + +### Publish + +```bash +# Publish with metadata +cone registry publish MyOrg/baton-myapp v1.0.0 \ + --binary-dir ./dist/ \ + --description "Initial release" \ + --changelog "- User sync\n- Group sync\n- Entitlement sync" \ + --license "Apache-2.0" +``` + +### Check Publication Status + +```bash +# View all your published versions +cone registry status + +# Filter by connector +cone registry status --connector MyOrg/baton-myapp + +# Filter by state +cone registry status --state VALIDATING +cone registry status --state PUBLISHED +cone registry status --state FAILED +``` + +--- + +## Part 5: Admin Commands + +These require admin role permissions. + +### Set Stable Version + +```bash +# Mark a version as the stable/recommended version +cone registry set-stable MyOrg/baton-myapp v1.0.0 +``` + +### Yank a Version + +```bash +# Withdraw a version (e.g., security issue) +cone registry yank MyOrg/baton-myapp v0.9.0 --reason "Security vulnerability CVE-2024-XXXX" +``` + +### Deprecate a Connector + +```bash +# Mark connector as deprecated +cone registry deprecate MyOrg/baton-legacy --reason "Replaced by baton-myapp" +``` + +--- + +## Part 6: Data Sync (Admin) + +Compare registry against dist.conductorone.com and sync changes. + +### Check Sync Status (Diff) + +```bash +# Compare registry (DynamoDB) against dist.conductorone.com +cone registry diff + +# Example output: +# Registry vs https://dist.conductorone.com +# Generated: 2026-01-07 15:30:05 +# +# Summary: +# Connectors: +1 added, -2 removed, ~49 modified +# Versions: +15 added, -0 removed +# +# Added Connectors (in registry, not in dist): +# + TestOrg/baton-test +# +# Removed Connectors (in dist, not in registry): +# - ConductorOne/baton-old +# +# Modified Connectors: +# ~ ConductorOne/baton-okta +# stable: v0.4.3 → v0.4.4 +# versions added: v0.4.4 +``` + +```bash +# Output as JSON for scripting +cone registry diff --output json + +# Compare against staging dist +cone registry diff --base-url https://staging-dist.conductorone.com +``` + +### Export to Dist (Push) + +```bash +# Preview what would be exported (dry run) +cone registry export --dry-run + +# Example output: +# Would export all connectors: +# Connectors: 50 +# Releases: 136 +# Files: 187 +# +# (dry run - no files written) +``` + +```bash +# Export all connectors to S3 (dist layout) +cone registry export + +# Export specific connectors only +cone registry export ConductorOne/baton-okta ConductorOne/baton-aws + +# Output as JSON +cone registry export --output json +``` + +### Sync Workflow + +The `sync` command provides convenient aliases: + +```bash +# Check what would change (alias for 'registry diff') +cone registry sync status + +# Push changes to dist (alias for 'registry export') +cone registry sync push --dry-run +cone registry sync push + +# Push specific connectors +cone registry sync push ConductorOne/baton-okta +``` + +### Typical Sync Workflow + +```bash +# 1. Check current differences +cone registry diff + +# 2. Review what will be exported +cone registry export --dry-run + +# 3. Export to dist +cone registry export + +# 4. Verify sync completed +cone registry diff +# Should show: "No differences found - registry matches dist." +``` + +--- + +## Part 7: JSON Output & Scripting + +All commands support JSON output for automation. + +```bash +# List as JSON +cone registry list --output json + +# Pretty-printed JSON +cone registry show ConductorOne/baton-okta --output json-pretty + +# Use with jq for filtering +cone registry list --output json | jq '.[] | select(.stableVersion != "")' + +# Get download URL programmatically +cone registry show ConductorOne/baton-okta v0.1.0 --output json | jq -r '.downloadUrl' +``` + +--- + +## Local Development Setup + +For testing against a local registry server with real connector data. + +### Start Local Infrastructure + +```bash +cd /path/to/connector-registry + +# Start LocalStack (DynamoDB + S3) +docker compose up -d localstack + +# Wait for LocalStack to be ready +until curl -s http://localhost:4566/_localstack/health | grep -q '"dynamodb": "running"'; do + sleep 1 +done +``` + +### Import Connector Data + +There are two ways to populate the registry with connector data: + +#### Option A: Live Import (requires network) + +```bash +# Fetch directly from dist.conductorone.com +make import + +# This fetches: +# - ~51 connectors from the live catalog +# - ~147 release manifests with platform/checksum info +# - Applies description overlay (167 connector descriptions) +``` + +#### Option B: Snapshot Import (offline capable) + +```bash +# First, download a snapshot (do this once while online) +make snapshot-download +# Saves to data/catalog_snapshot.json (~900KB) + +# Later, load from snapshot (works offline) +make snapshot-load +``` + +The snapshot approach is useful for: +- Air-gapped environments +- Reproducible demo data +- Faster local development (no network fetches) + +### Description Overlay + +The import applies rich descriptions from `pkg/importer/data/description_overlay.json`: + +- **baton-okta**: "Syncs users, groups, roles, applications, and custom roles from Okta..." +- **baton-aws**: "Syncs IAM users, groups, roles, and accounts with optional AWS Identity Center..." +- **baton-azure**: "Syncs Entra ID users, groups, roles, resource groups, tenants..." + +The overlay contains descriptions for 167 connectors (more than currently published). + +### Start the Registry Server + +```bash +# Basic local server +make run-local + +# Or with dev config (for testing auth) +AWS_ACCESS_KEY_ID=test \ +AWS_SECRET_ACCESS_KEY=test \ +AWS_REGION=us-east-1 \ +AWS_ENDPOINT=http://localhost:4566 \ +DYNAMODB_TABLE=connector-registry \ +S3_BUCKET=connector-registry-binaries \ +./registry-local serve --config=config.dev.json --port=8080 +``` + +### Test Against Local Server + +```bash +# All commands accept --registry-url flag +cone registry list --registry-url http://localhost:8080 + +# Should show 51 connectors with descriptions +cone registry list --registry-url http://localhost:8080 | wc -l +# Output: 52 (51 connectors + header) +``` + +### Test Authenticated Endpoints Locally + +The `config.dev.json` enables `skip_signature_verification` for testing without real JWTs: + +```bash +# Publisher test token (c1scp: ["publisher-scope"]) +export PUBLISHER_TOKEN="eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJpc3MiOiJ0ZXN0LWlzc3VlciIsImF1ZCI6WyJjb25uZWN0b3ItcmVnaXN0cnkiXSwiZXhwIjo0MTAyNDQ0ODAwLCJpYXQiOjE3MDQwNjcyMDAsIm9yZyI6IlRlc3RPcmciLCJyb2xlcyI6WyJwdWJsaXNoZXIiXSwiYzFzY3AiOlsicHVibGlzaGVyLXNjb3BlIl0sImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9." + +# Admin test token (c1scp: ["admin-scope"]) +export ADMIN_TOKEN="eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0ZXN0LWFkbWluIiwiaXNzIjoidGVzdC1pc3N1ZXIiLCJhdWQiOlsiY29ubmVjdG9yLXJlZ2lzdHJ5Il0sImV4cCI6NDEwMjQ0NDgwMCwiaWF0IjoxNzA0MDY3MjAwLCJjMXNjcCI6WyJhZG1pbi1zY29wZSJdLCJlbWFpbCI6ImFkbWluQHRlc3QuY29tIn0." + +# Test publisher commands +cone registry keys list TestOrg \ + --registry-url http://localhost:8080 \ + --registry-token "$PUBLISHER_TOKEN" + +cone registry status \ + --registry-url http://localhost:8080 \ + --registry-token "$PUBLISHER_TOKEN" + +# Test admin commands (diff/export) +cone registry diff \ + --registry-url http://localhost:8080 \ + --registry-token "$ADMIN_TOKEN" + +cone registry export --dry-run \ + --registry-url http://localhost:8080 \ + --registry-token "$ADMIN_TOKEN" + +# Export specific connector +cone registry export ConductorOne/baton-okta \ + --registry-url http://localhost:8080 \ + --registry-token "$ADMIN_TOKEN" \ + --dry-run +``` + +--- + +## Command Reference + +| Command | Auth | Description | +|---------|------|-------------| +| `registry list` | No | List all connectors | +| `registry show ` | No | Show connector details | +| `registry show ` | No | Show version details | +| `registry versions ` | No | List all versions | +| `registry download ` | No | Download binary | +| `registry keys generate ` | No | Generate key pair locally | +| `registry keys list ` | Publisher | List org's signing keys | +| `registry keys add ` | Publisher | Register a signing key | +| `registry keys show ` | Publisher | Show key details | +| `registry status` | Publisher | Show your published versions | +| `registry publish ` | Publisher | Publish a new version | +| `registry set-stable ` | Admin | Set stable version | +| `registry yank ` | Admin | Yank a version | +| `registry deprecate ` | Admin | Deprecate connector | +| `registry diff` | Admin | Compare registry vs dist | +| `registry export [connectors...]` | Admin | Export to dist layout (S3) | +| `registry sync status` | Admin | Alias for `diff` | +| `registry sync push [connectors...]` | Admin | Alias for `export` | + +--- + +## Troubleshooting + +### "authentication required" + +```bash +cone login your-tenant.conductor.one +cone whoami # verify +``` + +### "no stable version available" + +Specify version explicitly: +```bash +cone registry download ConductorOne/baton-okta v0.1.0 +``` + +### "checksum verification failed" + +Try again or skip verification: +```bash +cone registry download ConductorOne/baton-okta --skip-verify +``` + +### "connector not found" + +Check spelling and org/name format: +```bash +cone registry list | grep -i okta +``` + +### "permission denied" + +Verify your ConductorOne account has the required scope: +```bash +# Check your token claims +cone whoami --output json | jq '.scopes' +``` diff --git a/Makefile b/Makefile index 455f6127..f5d300a3 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,18 @@ add-dep: go mod tidy -v go mod vendor +.PHONY: test +test: + go test ./... + .PHONY: lint lint: golangci-lint run +.PHONY: install-hooks +install-hooks: + @echo '#!/bin/sh' > .git/hooks/pre-push + @echo 'make lint' >> .git/hooks/pre-push + @chmod +x .git/hooks/pre-push + @echo "Installed pre-push hook to run 'make lint'" + diff --git a/cmd/cone/connector.go b/cmd/cone/connector.go new file mode 100644 index 00000000..3fc7b797 --- /dev/null +++ b/cmd/cone/connector.go @@ -0,0 +1,29 @@ +package main + +import ( + "github.com/spf13/cobra" +) + +// connectorCmd returns the root command for connector operations +// (subcommands: init, dev, build). +func connectorCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "connector", + Short: "Manage ConductorOne connectors", + Long: `Commands for developing, building, and managing ConductorOne connectors. + +The connector subcommands help you: + - Initialize new connector projects + - Run a local development server with hot reload + - Build connector binaries for deployment + - Publish connectors to the ConductorOne registry`, + } + + cmd.AddCommand(connectorBuildCmd()) + cmd.AddCommand(connectorInitCmd()) + cmd.AddCommand(connectorDevCmd()) + cmd.AddCommand(connectorPublishCmd()) + cmd.AddCommand(connectorValidateConfigCmd()) + + return cmd +} diff --git a/cmd/cone/connector_build.go b/cmd/cone/connector_build.go new file mode 100644 index 00000000..891915aa --- /dev/null +++ b/cmd/cone/connector_build.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/spf13/cobra" +) + +// connectorBuildCmd returns the command for building connector binaries. +func connectorBuildCmd() *cobra.Command { + var outputPath string + var targetOS string + var targetArch string + + cmd := &cobra.Command{ + Use: "build [path]", + Short: "Build a connector binary", + Long: `Build a connector binary from the specified path. + +If no path is provided, builds from the current directory. + +Examples: + cone connector build + cone connector build ./my-connector + cone connector build -o ./dist/connector + cone connector build --os linux --arch amd64`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + buildPath := "." + if len(args) > 0 { + buildPath = args[0] + } + + // Resolve absolute path + absPath, err := filepath.Abs(buildPath) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + // Check if directory exists and contains go.mod + goModPath := filepath.Join(absPath, "go.mod") + if _, err := os.Stat(goModPath); os.IsNotExist(err) { + return fmt.Errorf("no go.mod found in %s - is this a Go project?", absPath) + } + + // Determine output path + if outputPath == "" { + outputPath = filepath.Join(absPath, "connector") + if targetOS == "windows" || runtime.GOOS == "windows" { + outputPath += ".exe" + } + } + + // Set up build environment + buildEnv := os.Environ() + if targetOS != "" { + buildEnv = append(buildEnv, "GOOS="+targetOS) + } + if targetArch != "" { + buildEnv = append(buildEnv, "GOARCH="+targetArch) + } + + // Build the connector + // Template creates main.go at root, so build from "." + buildCmd := exec.CommandContext(cmd.Context(), "go", "build", "-o", outputPath, ".") + buildCmd.Dir = absPath + buildCmd.Env = buildEnv + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + + fmt.Printf("Building connector in %s...\n", absPath) + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + fmt.Printf("Built: %s\n", outputPath) + return nil + }, + } + + cmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output path for the binary") + cmd.Flags().StringVar(&targetOS, "os", "", "Target operating system (e.g., linux, darwin, windows)") + cmd.Flags().StringVar(&targetArch, "arch", "", "Target architecture (e.g., amd64, arm64)") + + return cmd +} diff --git a/cmd/cone/connector_dev.go b/cmd/cone/connector_dev.go new file mode 100644 index 00000000..3b4df7fa --- /dev/null +++ b/cmd/cone/connector_dev.go @@ -0,0 +1,250 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/spf13/cobra" +) + +// connectorDevCmd returns the command for running a local development server. +// It watches for file changes and automatically rebuilds/restarts the connector. +func connectorDevCmd() *cobra.Command { + var port int + var noWatch bool + + cmd := &cobra.Command{ + Use: "dev [path]", + Short: "Run a connector in development mode with hot reload", + Long: `Run a connector in development mode with automatic rebuilding on file changes. + +This command: +1. Builds the connector +2. Runs it with the specified flags +3. Watches for .go file changes +4. Automatically rebuilds and restarts on changes + +Press Ctrl+C to stop. + +Examples: + cone connector dev + cone connector dev ./my-connector + cone connector dev --no-watch`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + devPath := "." + if len(args) > 0 { + devPath = args[0] + } + + absPath, err := filepath.Abs(devPath) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + // Check if directory contains go.mod + goModPath := filepath.Join(absPath, "go.mod") + if _, err := os.Stat(goModPath); os.IsNotExist(err) { + return fmt.Errorf("no go.mod found in %s - is this a Go project?", absPath) + } + + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + // Handle shutdown signals + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Println("\nShutting down...") + cancel() + }() + + if noWatch { + // Just build and run once + return buildAndRun(ctx, absPath, port) + } + + // Watch mode: rebuild on file changes + return watchAndRun(ctx, absPath, port) + }, + } + + cmd.Flags().IntVarP(&port, "port", "P", 8080, "Port for the connector to listen on (if applicable)") + cmd.Flags().BoolVar(&noWatch, "no-watch", false, "Disable file watching (run once)") + + return cmd +} + +// buildAndRun builds the connector and runs it. +func buildAndRun(ctx context.Context, path string, port int) error { + binaryPath := filepath.Join(path, "connector-dev") + + // Build + fmt.Println("Building connector...") + buildCmd := exec.CommandContext(ctx, "go", "build", "-o", binaryPath, ".") + buildCmd.Dir = path + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + // Run + fmt.Printf("Starting connector (port %d)...\n", port) + runCmd := exec.CommandContext(ctx, binaryPath) + runCmd.Dir = path + runCmd.Stdout = os.Stdout + runCmd.Stderr = os.Stderr + runCmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", port)) + + if err := runCmd.Run(); err != nil { + // Context cancelled is expected on shutdown + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("connector exited with error: %w", err) + } + + return nil +} + +// watchAndRun watches for file changes and rebuilds/restarts the connector. +func watchAndRun(ctx context.Context, path string, port int) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create file watcher: %w", err) + } + defer watcher.Close() + + // Add directories to watch + if err := addWatchDirs(watcher, path); err != nil { + return fmt.Errorf("failed to watch directories: %w", err) + } + + var runCmd *exec.Cmd + var runCancel context.CancelFunc + binaryPath := filepath.Join(path, "connector-dev") + + // Initial build and run + build := func() error { + fmt.Println("\n[dev] Building connector...") + buildCmd := exec.CommandContext(ctx, "go", "build", "-o", binaryPath, ".") + buildCmd.Dir = path + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + + if err := buildCmd.Run(); err != nil { + fmt.Printf("[dev] Build failed: %v\n", err) + return err + } + fmt.Println("[dev] Build successful") + return nil + } + + start := func() { + // Stop previous run if any + if runCancel != nil { + runCancel() + } + if runCmd != nil && runCmd.Process != nil { + _ = runCmd.Process.Kill() + _ = runCmd.Wait() + } + + fmt.Printf("[dev] Starting connector (port %d)...\n", port) + var runCtx context.Context + runCtx, runCancel = context.WithCancel(ctx) + runCmd = exec.CommandContext(runCtx, binaryPath) + runCmd.Dir = path + runCmd.Stdout = os.Stdout + runCmd.Stderr = os.Stderr + runCmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", port)) + + go func() { + if err := runCmd.Run(); err != nil && runCtx.Err() == nil { + fmt.Printf("[dev] Connector exited: %v\n", err) + } + }() + } + + // Initial build and run + if err := build(); err != nil { + fmt.Println("[dev] Initial build failed, waiting for file changes...") + } else { + start() + } + + // Debounce timer for file changes + var debounceTimer *time.Timer + debounce := func() { + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(500*time.Millisecond, func() { + if err := build(); err == nil { + start() + } + }) + } + + fmt.Println("[dev] Watching for file changes... (Ctrl+C to stop)") + + for { + select { + case <-ctx.Done(): + if runCancel != nil { + runCancel() + } + // Clean up binary + _ = os.Remove(binaryPath) + return nil + + case event, ok := <-watcher.Events: + if !ok { + return nil + } + // Only watch .go files + if filepath.Ext(event.Name) == ".go" { + if event.Op&(fsnotify.Write|fsnotify.Create) != 0 { + fmt.Printf("[dev] Change detected: %s\n", filepath.Base(event.Name)) + debounce() + } + } + + case err, ok := <-watcher.Errors: + if !ok { + return nil + } + fmt.Printf("[dev] Watch error: %v\n", err) + } + } +} + +// addWatchDirs adds all directories containing .go files to the watcher. +func addWatchDirs(watcher *fsnotify.Watcher, root string) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip vendor, .git, and other hidden directories + if info.IsDir() { + name := info.Name() + if name == "vendor" || name == ".git" || (name[0] == '.' && len(name) > 1) { + return filepath.SkipDir + } + return watcher.Add(path) + } + + return nil + }) +} diff --git a/cmd/cone/connector_init.go b/cmd/cone/connector_init.go new file mode 100644 index 00000000..13aa078a --- /dev/null +++ b/cmd/cone/connector_init.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/conductorone/cone/pkg/scaffold" + "github.com/spf13/cobra" +) + +// connectorInitCmd returns the command for initializing new connector projects. +func connectorInitCmd() *cobra.Command { + var modulePath string + var description string + + cmd := &cobra.Command{ + Use: "init ", + Short: "Create a new connector project", + Long: `Create a new ConductorOne connector project from the standard template. + +The project will be created in a directory named "baton-" in the current +working directory. + +Examples: + cone connector init my-app + cone connector init my-app --module github.com/myorg/baton-my-app + cone connector init my-app --description "Connector for My App"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + + // Normalize name (remove baton- prefix if present) + name = strings.TrimPrefix(name, "baton-") + + // Determine output directory + outputDir := fmt.Sprintf("baton-%s", name) + + // Check if directory exists + if _, err := os.Stat(outputDir); !os.IsNotExist(err) { + return fmt.Errorf("directory already exists: %s", outputDir) + } + + cfg := &scaffold.Config{ + Name: name, + ModulePath: modulePath, + OutputDir: outputDir, + Description: description, + } + + fmt.Printf("Creating connector project: %s\n", outputDir) + + if err := scaffold.Generate(cfg); err != nil { + return fmt.Errorf("failed to generate project: %w", err) + } + + // Verify Go installation + fmt.Println("\nProject created successfully!") + fmt.Println("\nNext steps:") + fmt.Printf(" cd %s\n", outputDir) + fmt.Println(" go mod tidy") + fmt.Println(" # Edit pkg/client/client.go to implement API calls") + fmt.Println(" # Edit pkg/connector/*.go to implement resource syncers") + fmt.Println(" go build") + fmt.Println(" cone connector dev") + + return nil + }, + } + + cmd.Flags().StringVarP(&modulePath, "module", "m", "", "Go module path (default: github.com/conductorone/baton-)") + cmd.Flags().StringVarP(&description, "description", "d", "", "Connector description") + + return cmd +} diff --git a/cmd/cone/connector_publish.go b/cmd/cone/connector_publish.go new file mode 100644 index 00000000..e86ca461 --- /dev/null +++ b/cmd/cone/connector_publish.go @@ -0,0 +1,831 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + c1client "github.com/conductorone/cone/pkg/client" + "github.com/spf13/cobra" +) + +// connectorPublishCmd creates the publish command for uploading connectors to the registry. +func connectorPublishCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "publish", + Short: "Publish connector to the ConductorOne registry", + Long: `Publish a connector version to the ConductorOne registry. + +This command performs the following steps: + 1. Reads connector metadata from go.mod and connector.yaml + 2. Finds built binaries in the dist/ directory + 3. Creates a new version in the registry + 4. Uploads binaries for each platform + 5. Uploads checksums + 6. Finalizes the version + +Prerequisites: + - Run 'cone login' first to authenticate + - Build binaries with 'make build' or 'goreleaser' + - Have a connector.yaml with metadata (optional but recommended)`, + Example: ` # Publish from current directory + cone connector publish --version v1.0.0 + + # Publish with specific binary directory + cone connector publish --version v1.0.0 --dist ./dist + + # Dry run to see what would be published + cone connector publish --version v1.0.0 --dry-run + + # Publish specific platforms only + cone connector publish --version v1.0.0 --platform linux-amd64 --platform darwin-arm64`, + RunE: runConnectorPublish, + } + + cmd.Flags().String("version", "", "Version to publish (e.g., v1.0.0)") + cmd.Flags().String("dist", "dist", "Directory containing built binaries") + cmd.Flags().StringSlice("platform", nil, "Platforms to publish (default: auto-detect)") + cmd.Flags().Bool("dry-run", false, "Show what would be published without publishing") + cmd.Flags().String("registry-url", "https://registry.conductorone.com", "Registry API URL") + cmd.Flags().String("signing-key", "", "Signing key ID for this release") + + _ = cmd.MarkFlagRequired("version") + + return cmd +} + +func runConnectorPublish(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + // Get flags + version, _ := cmd.Flags().GetString("version") + distDir, _ := cmd.Flags().GetString("dist") + platforms, _ := cmd.Flags().GetStringSlice("platform") + dryRun, _ := cmd.Flags().GetBool("dry-run") + registryURL, _ := cmd.Flags().GetString("registry-url") + signingKey, _ := cmd.Flags().GetString("signing-key") + + // Validate version format + if !isValidVersion(version) { + return fmt.Errorf("invalid version format %q, expected semver like v1.0.0", version) + } + + // Read connector metadata + metadata, err := readConnectorMetadata() + if err != nil { + return fmt.Errorf("failed to read connector metadata: %w", err) + } + + fmt.Printf("Publishing %s/%s@%s\n", metadata.Org, metadata.Name, version) + + // Find binaries + binaries, err := findPublishBinaries(distDir, metadata.Name, platforms) + if err != nil { + return fmt.Errorf("failed to find binaries: %w", err) + } + + if len(binaries) == 0 { + return fmt.Errorf("no binaries found in %s", distDir) + } + + fmt.Printf("Found %d platform(s):\n", len(binaries)) + for _, b := range binaries { + fmt.Printf(" - %s (%s, %d bytes)\n", b.Platform, b.Filename, b.Size) + } + + if dryRun { + fmt.Println("\nDry run - no changes made") + return nil + } + + // Get auth token + token, err := getAuthToken(ctx, cmd) + if err != nil { + return fmt.Errorf("not authenticated, run 'cone login' first: %w", err) + } + + // Create registry client + client := newRegistryClient(registryURL, token) + + // Step 0: Ensure connector exists + fmt.Println("\nEnsuring connector exists...") + if err := client.EnsureConnector(ctx, metadata.Org, metadata.Name); err != nil { + return fmt.Errorf("failed to ensure connector exists: %w", err) + } + + // Step 1: Create version + fmt.Println("Creating version...") + platformNames := make([]string, len(binaries)) + for i, b := range binaries { + platformNames[i] = b.Platform + } + + _, err = client.CreateVersion(ctx, &createVersionRequest{ + Org: metadata.Org, + Name: metadata.Name, + Version: version, + Description: metadata.Description, + RepositoryURL: metadata.RepositoryURL, + HomepageURL: metadata.HomepageURL, + License: metadata.License, + Changelog: metadata.Changelog, + CommitSHA: getGitCommitSHA(), + Platforms: platformNames, + SigningKeyID: signingKey, + }) + if err != nil { + return fmt.Errorf("failed to create version: %w", err) + } + + fmt.Printf("Created version %s (state: PENDING)\n", version) + + // Step 2: Get upload URLs + fmt.Println("\nGetting upload URLs...") + uploadResp, err := client.GetUploadURLs(ctx, &getUploadURLsRequest{ + Org: metadata.Org, + Name: metadata.Name, + Version: version, + Platforms: platformNames, + }) + if err != nil { + return fmt.Errorf("failed to get upload URLs: %w", err) + } + + // Step 3: Upload binaries + fmt.Println("Uploading binaries...") + var assetMetadata []assetMeta + for _, binary := range binaries { + fmt.Printf(" Uploading %s...", binary.Platform) + + // Get upload URL + uploadKey := fmt.Sprintf("%s/binary", binary.Platform) + uploadURL, ok := uploadResp.UploadURLs[uploadKey] + if !ok { + fmt.Println(" SKIP (no upload URL)") + continue + } + + // Upload binary + if err := uploadFile(ctx, uploadURL, binary.Path); err != nil { + return fmt.Errorf("failed to upload %s: %w", binary.Platform, err) + } + + // Upload checksum + checksumKey := fmt.Sprintf("%s/checksum", binary.Platform) + if checksumURL, ok := uploadResp.UploadURLs[checksumKey]; ok { + checksumContent := fmt.Sprintf("%s %s\n", binary.Checksum, binary.Filename) + if err := uploadContent(ctx, checksumURL, []byte(checksumContent)); err != nil { + return fmt.Errorf("failed to upload checksum for %s: %w", binary.Platform, err) + } + } + + // Filename must match the registry's expected format: {name}-{version}-{platform}.tar.gz + registryFilename := fmt.Sprintf("%s-%s-%s.tar.gz", metadata.Name, version, binary.Platform) + assetMetadata = append(assetMetadata, assetMeta{ + Platform: binary.Platform, + Filename: registryFilename, + SHA256: binary.Checksum, + SizeBytes: binary.Size, + MediaType: "application/gzip", + }) + + fmt.Println(" OK") + } + + // Step 4: Finalize version + fmt.Println("\nFinalizing version...") + finalResp, err := client.FinalizeVersion(ctx, &finalizeVersionRequest{ + Org: metadata.Org, + Name: metadata.Name, + Version: version, + Assets: assetMetadata, + }) + if err != nil { + return fmt.Errorf("failed to finalize version: %w", err) + } + + if finalResp.Release.State == "FAILED" { + return fmt.Errorf("version validation failed: %s", finalResp.Release.FailureReason) + } + + fmt.Printf("\nPublished %s/%s@%s\n", metadata.Org, metadata.Name, version) + fmt.Printf("View at: %s/connectors/%s/%s\n", registryURL, metadata.Org, metadata.Name) + + return nil +} + +// connectorMetadata holds connector information for publishing. +type connectorMetadata struct { + Org string + Name string + Description string + RepositoryURL string + HomepageURL string + License string + Changelog string +} + +// readConnectorMetadata reads metadata from go.mod and connector.yaml. +func readConnectorMetadata() (*connectorMetadata, error) { + // Read module path from go.mod + modulePath, err := readModulePath() + if err != nil { + return nil, fmt.Errorf("failed to read go.mod: %w", err) + } + + // Parse org and name from module path + // Expected: github.com/org/baton-name + parts := strings.Split(modulePath, "/") + if len(parts) < 3 { + return nil, fmt.Errorf("invalid module path %q, expected github.com/org/name", modulePath) + } + + org := parts[len(parts)-2] + name := parts[len(parts)-1] + + // Strip "baton-" prefix if present for registry name + registryName := strings.TrimPrefix(name, "baton-") + + metadata := &connectorMetadata{ + Org: org, + Name: registryName, + } + + // Try to read connector.yaml for additional metadata + if data, err := os.ReadFile("connector.yaml"); err == nil { + parseConnectorYAML(data, metadata) + } + + // Default repository URL from module path + if metadata.RepositoryURL == "" { + metadata.RepositoryURL = "https://" + modulePath + } + + return metadata, nil +} + +// readModulePath reads the module path from go.mod. +func readModulePath() (string, error) { + data, err := os.ReadFile("go.mod") + if err != nil { + return "", err + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + return strings.TrimPrefix(line, "module "), nil + } + } + + return "", fmt.Errorf("module directive not found in go.mod") +} + +// parseConnectorYAML parses connector.yaml into metadata. +// Simple YAML parsing without external dependency. +func parseConnectorYAML(data []byte, metadata *connectorMetadata) { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "description:"): + metadata.Description = strings.TrimSpace(strings.TrimPrefix(line, "description:")) + case strings.HasPrefix(line, "license:"): + metadata.License = strings.TrimSpace(strings.TrimPrefix(line, "license:")) + case strings.HasPrefix(line, "homepage_url:"): + metadata.HomepageURL = strings.TrimSpace(strings.TrimPrefix(line, "homepage_url:")) + case strings.HasPrefix(line, "repository_url:"): + metadata.RepositoryURL = strings.TrimSpace(strings.TrimPrefix(line, "repository_url:")) + } + } +} + +// publishBinary represents a binary to publish. +type publishBinary struct { + Platform string + Path string + Filename string + Checksum string + Size int64 +} + +// findPublishBinaries finds binaries in the dist directory. +func findPublishBinaries(distDir, connectorName string, platforms []string) ([]publishBinary, error) { + var binaries []publishBinary + + // If platforms specified, only look for those + if len(platforms) > 0 { + for _, platform := range platforms { + binary, err := findBinaryForPlatform(distDir, connectorName, platform) + if err != nil { + return nil, fmt.Errorf("platform %s: %w", platform, err) + } + binaries = append(binaries, *binary) + } + return binaries, nil + } + + // Auto-detect platforms by scanning dist directory + entries, err := os.ReadDir(distDir) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if entry.IsDir() { + // Check for platform directories (e.g., linux_amd64, darwin_arm64) + platform := normalizePlatform(entry.Name()) + if platform != "" { + binary, err := findBinaryForPlatform(distDir, connectorName, platform) + if err == nil { + binaries = append(binaries, *binary) + } + } + } else { + // Check for direct binary files with platform suffix + name := entry.Name() + if strings.HasPrefix(name, connectorName) || strings.HasPrefix(name, "baton-"+connectorName) { + platform := extractPlatformFromFilename(name) + if platform != "" { + path := filepath.Join(distDir, name) + checksum, size, err := computeFileChecksum(path) + if err != nil { + continue + } + binaries = append(binaries, publishBinary{ + Platform: platform, + Path: path, + Filename: name, + Checksum: checksum, + Size: size, + }) + } + } + } + } + + return binaries, nil +} + +// findBinaryForPlatform finds a specific platform binary. +func findBinaryForPlatform(distDir, connectorName, platform string) (*publishBinary, error) { + // Try various naming conventions + patterns := []string{ + filepath.Join(distDir, platform, connectorName), + filepath.Join(distDir, platform, "baton-"+connectorName), + filepath.Join(distDir, strings.ReplaceAll(platform, "-", "_"), connectorName), + filepath.Join(distDir, strings.ReplaceAll(platform, "-", "_"), "baton-"+connectorName), + filepath.Join(distDir, fmt.Sprintf("%s_%s", connectorName, platform)), + filepath.Join(distDir, fmt.Sprintf("baton-%s_%s", connectorName, platform)), + } + + // Add .exe suffix for Windows + if strings.HasPrefix(platform, "windows") { + for i := range patterns { + patterns = append(patterns, patterns[i]+".exe") + } + } + + for _, path := range patterns { + if info, err := os.Stat(path); err == nil && !info.IsDir() { + checksum, size, err := computeFileChecksum(path) + if err != nil { + return nil, err + } + return &publishBinary{ + Platform: platform, + Path: path, + Filename: filepath.Base(path), + Checksum: checksum, + Size: size, + }, nil + } + } + + return nil, fmt.Errorf("binary not found") +} + +// normalizePlatform converts directory names to platform strings. +func normalizePlatform(name string) string { + // Convert goreleaser-style names (linux_amd64) to registry style (linux-amd64) + name = strings.ReplaceAll(name, "_", "-") + + // Validate it looks like a platform + parts := strings.Split(name, "-") + if len(parts) != 2 { + return "" + } + + os := parts[0] + arch := parts[1] + + validOS := map[string]bool{"linux": true, "darwin": true, "windows": true} + validArch := map[string]bool{"amd64": true, "arm64": true, "386": true} + + if validOS[os] && validArch[arch] { + return name + } + return "" +} + +// extractPlatformFromFilename extracts platform from filename. +func extractPlatformFromFilename(name string) string { + // Handle patterns like: baton-okta_linux_amd64, baton-okta-linux-amd64 + name = strings.TrimSuffix(name, ".exe") + + for _, sep := range []string{"_", "-"} { + parts := strings.Split(name, sep) + if len(parts) >= 3 { + os := parts[len(parts)-2] + arch := parts[len(parts)-1] + platform := normalizePlatform(os + "-" + arch) + if platform != "" { + return platform + } + } + } + + return "" +} + +// computeFileChecksum computes SHA256 checksum of a file. +func computeFileChecksum(path string) (string, int64, error) { + f, err := os.Open(path) + if err != nil { + return "", 0, err + } + defer f.Close() + + h := sha256.New() + size, err := io.Copy(h, f) + if err != nil { + return "", 0, err + } + + return hex.EncodeToString(h.Sum(nil)), size, nil +} + +// isValidVersion validates semantic version format. +func isValidVersion(v string) bool { + if !strings.HasPrefix(v, "v") { + return false + } + parts := strings.Split(strings.TrimPrefix(v, "v"), ".") + if len(parts) < 3 { + return false + } + // Basic validation - just check it starts with v and has dots + return true +} + +// getGitCommitSHA returns the current git commit SHA. +func getGitCommitSHA() string { + // Try to read from .git/HEAD + data, err := os.ReadFile(".git/HEAD") + if err != nil { + return "" + } + + content := strings.TrimSpace(string(data)) + if strings.HasPrefix(content, "ref: ") { + // It's a symbolic ref, read the actual ref + refPath := strings.TrimPrefix(content, "ref: ") + data, err = os.ReadFile(filepath.Join(".git", refPath)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) + } + return content +} + +// getAuthToken retrieves the auth token for API calls using cone's OAuth credential flow. +func getAuthToken(ctx context.Context, cmd *cobra.Command) (string, error) { + // Allow env var override for CI/automation + if token := os.Getenv("CONE_REGISTRY_TOKEN"); token != "" { + return token, nil + } + + // Use cone's OAuth credential flow (same as other commands) + v, err := getSubViperForProfile(cmd) + if err != nil { + return "", fmt.Errorf("failed to get config: %w", err) + } + + clientId, clientSecret, err := getCredentials(v) + if err != nil { + return "", fmt.Errorf("no credentials available. Run 'cone login' first: %w", err) + } + + tokenSrc, _, _, err := c1client.NewC1TokenSource(ctx, clientId, clientSecret, v.GetString("api-endpoint"), v.GetBool("debug")) + if err != nil { + return "", fmt.Errorf("failed to create token source: %w", err) + } + + token, err := tokenSrc.Token() + if err != nil { + return "", fmt.Errorf("failed to get auth token: %w", err) + } + + return token.AccessToken, nil +} + +// Registry client types and methods + +type registryClient struct { + baseURL string + token string + httpClient *http.Client +} + +func newRegistryClient(baseURL, token string) *registryClient { + return ®istryClient{ + baseURL: baseURL, + token: token, + httpClient: &http.Client{}, + } +} + +type createConnectorRequest struct { + Org string `json:"org"` + Name string `json:"name"` +} + +type createVersionRequest struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + HomepageURL string `json:"homepage_url,omitempty"` + License string `json:"license,omitempty"` + Changelog string `json:"changelog,omitempty"` + CommitSHA string `json:"commit_sha,omitempty"` + Platforms []string `json:"platforms"` + SigningKeyID string `json:"signing_key_id,omitempty"` +} + +type createVersionResponse struct { + Release releaseManifest `json:"release"` +} + +type getUploadURLsRequest struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + Platforms []string `json:"platforms"` +} + +type getUploadURLsResponse struct { + UploadURLs map[string]string `json:"uploadUrls"` +} + +type releaseManifest struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + State string `json:"state"` + FailureReason string `json:"failure_reason,omitempty"` +} + +type finalizeVersionRequest struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + Assets []assetMeta `json:"assets"` +} + +type assetMeta struct { + Platform string `json:"platform"` + Filename string `json:"filename"` + SHA256 string `json:"sha256"` + SizeBytes int64 `json:"size_bytes"` + MediaType string `json:"media_type"` +} + +type finalizeVersionResponse struct { + Release releaseManifest `json:"release"` +} + +// EnsureConnector creates the connector if it doesn't exist. +// Returns nil if connector already exists or was created successfully. +func (c *registryClient) EnsureConnector(ctx context.Context, org, name string) error { + url := fmt.Sprintf("%s/api/v1/connectors", c.baseURL) + + reqBody := createConnectorRequest{Org: org, Name: name} + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "http.MethodPost", url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if c.token != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // 200 OK = connector returned (already exists or created) + // 201 Created = new connector + // 409 Conflict = already exists (that's fine) + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusConflict { + return nil + } + + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to ensure connector exists (status %d): %s", resp.StatusCode, string(respBody)) +} + +func (c *registryClient) CreateVersion(ctx context.Context, req *createVersionRequest) (*createVersionResponse, error) { + url := fmt.Sprintf("%s/api/v1/connectors/%s/%s/versions", c.baseURL, req.Org, req.Name) + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "http.MethodPost", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if c.token != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result createVersionResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *registryClient) GetUploadURLs(ctx context.Context, req *getUploadURLsRequest) (*getUploadURLsResponse, error) { + url := fmt.Sprintf("%s/api/v1/connectors/%s/%s/versions/%s/upload-urls", c.baseURL, req.Org, req.Name, req.Version) + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "http.MethodPost", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if c.token != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result getUploadURLsResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *registryClient) FinalizeVersion(ctx context.Context, req *finalizeVersionRequest) (*finalizeVersionResponse, error) { + url := fmt.Sprintf("%s/api/v1/connectors/%s/%s/versions/%s/finalize", c.baseURL, req.Org, req.Name, req.Version) + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "http.MethodPost", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if c.token != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result finalizeVersionResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func uploadFile(ctx context.Context, url, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "http.MethodPut", url, f) + if err != nil { + return err + } + req.ContentLength = info.Size() + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return fmt.Errorf("upload failed with status %d", resp.StatusCode) + } + + return nil +} + +func uploadContent(ctx context.Context, url string, content []byte) error { + req, err := http.NewRequestWithContext(ctx, "http.MethodPut", url, strings.NewReader(string(content))) + if err != nil { + return err + } + req.ContentLength = int64(len(content)) + req.Header.Set("Content-Type", "text/plain") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return fmt.Errorf("upload failed with status %d", resp.StatusCode) + } + + return nil +} diff --git a/cmd/cone/connector_test.go b/cmd/cone/connector_test.go new file mode 100644 index 00000000..3623e062 --- /dev/null +++ b/cmd/cone/connector_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "testing" +) + +func TestConnectorCmd(t *testing.T) { + cmd := connectorCmd() + + if cmd.Use != "connector" { + t.Errorf("expected Use to be 'connector', got %s", cmd.Use) + } + if cmd.Short != "Manage ConductorOne connectors" { + t.Errorf("expected Short to be 'Manage ConductorOne connectors', got %s", cmd.Short) + } + if !cmd.HasSubCommands() { + t.Error("connector command should have subcommands") + } +} + +func TestConnectorBuildCmd(t *testing.T) { + cmd := connectorBuildCmd() + + if cmd.Use != "build [path]" { + t.Errorf("expected Use to be 'build [path]', got %s", cmd.Use) + } + if cmd.Short != "Build a connector binary" { + t.Errorf("expected Short to be 'Build a connector binary', got %s", cmd.Short) + } + + // Verify flags exist + outputFlag := cmd.Flag("output") + if outputFlag == nil { + t.Error("should have --output flag") + } else if outputFlag.Shorthand != "o" { + t.Errorf("expected output shorthand to be 'o', got %s", outputFlag.Shorthand) + } + + osFlag := cmd.Flag("os") + if osFlag == nil { + t.Error("should have --os flag") + } + + archFlag := cmd.Flag("arch") + if archFlag == nil { + t.Error("should have --arch flag") + } +} diff --git a/cmd/cone/connector_validate.go b/cmd/cone/connector_validate.go new file mode 100644 index 00000000..b4647b11 --- /dev/null +++ b/cmd/cone/connector_validate.go @@ -0,0 +1,263 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func connectorValidateConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "validate-config ", + Short: "Validate a meta-connector mapping configuration", + Long: `Validate a mapping configuration file for meta-connectors like baton-openapi. + +This checks: + - Required fields are present + - Field values are valid + - At least one TRAIT_USER resource exists + - Entitlements have grantable_to defined + - No duplicate resource types`, + Example: ` cone connector validate-config mapping.yaml + cone connector validate-config examples/github/mapping.yaml`, + Args: cobra.ExactArgs(1), + RunE: runValidateConfig, + } + return cmd +} + +func runValidateConfig(cmd *cobra.Command, args []string) error { + configPath := args[0] + + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + var config MappingConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse YAML: %w", err) + } + + if err := config.Validate(); err != nil { + return err + } + + fmt.Printf("Valid: %s\n", configPath) + fmt.Printf(" Name: %s\n", config.Name) + fmt.Printf(" Resources: %d\n", len(config.Resources)) + for _, r := range config.Resources { + entCount := len(r.Entitlements) + fmt.Printf(" - %s (%s) [%d entitlements]\n", r.Type, r.Trait, entCount) + } + + return nil +} + +// MappingConfig is the root configuration for meta-connectors. +type MappingConfig struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Resources []ResourceConfig `yaml:"resources"` +} + +// ResourceConfig defines how to sync a resource type. +type ResourceConfig struct { + Type string `yaml:"type"` + DisplayName string `yaml:"display_name"` + Trait string `yaml:"trait"` + List ListConfig `yaml:"list"` + Fields FieldMapping `yaml:"fields"` + Entitlements []EntitlementConfig `yaml:"entitlements,omitempty"` +} + +// ListConfig defines how to list resources. +type ListConfig struct { + Endpoint string `yaml:"endpoint"` + Method string `yaml:"method,omitempty"` + ResponsePath string `yaml:"response_path,omitempty"` + Pagination *PaginationConfig `yaml:"pagination,omitempty"` +} + +// PaginationConfig defines pagination behavior. +type PaginationConfig struct { + Type string `yaml:"type"` + CursorParam string `yaml:"cursor_param,omitempty"` + CursorPath string `yaml:"cursor_path,omitempty"` + OffsetParam string `yaml:"offset_param,omitempty"` + LimitParam string `yaml:"limit_param,omitempty"` + PageSize int `yaml:"page_size,omitempty"` +} + +// FieldMapping maps API fields to baton fields. +type FieldMapping struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + Email string `yaml:"email,omitempty"` + Description string `yaml:"description,omitempty"` + Status string `yaml:"status,omitempty"` + Profile map[string]string `yaml:"profile,omitempty"` +} + +// EntitlementConfig defines an entitlement. +type EntitlementConfig struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + Description string `yaml:"description,omitempty"` + GrantableTo []string `yaml:"grantable_to"` + Grants *GrantsConfig `yaml:"grants,omitempty"` + Provisioning *ProvisionConfig `yaml:"provisioning,omitempty"` +} + +// GrantsConfig defines how to fetch grants. +type GrantsConfig struct { + Endpoint string `yaml:"endpoint"` + Method string `yaml:"method,omitempty"` + ResponsePath string `yaml:"response_path,omitempty"` + PrincipalIDPath string `yaml:"principal_id_path"` + PrincipalType string `yaml:"principal_type"` +} + +// ProvisionConfig defines provisioning actions. +type ProvisionConfig struct { + Grant *ProvisionAction `yaml:"grant,omitempty"` + Revoke *ProvisionAction `yaml:"revoke,omitempty"` +} + +// ProvisionAction defines a single provisioning call. +type ProvisionAction struct { + Endpoint string `yaml:"endpoint"` + Method string `yaml:"method"` + Body map[string]any `yaml:"body,omitempty"` +} + +// Validate checks the configuration for errors. +func (c *MappingConfig) Validate() error { + var errs []string + + if c.Name == "" { + errs = append(errs, "name is required") + } + + if len(c.Resources) == 0 { + errs = append(errs, "at least one resource is required") + } + + hasUser := false + resourceTypes := make(map[string]bool) + validTraits := map[string]bool{ + "": true, + "TRAIT_USER": true, + "TRAIT_GROUP": true, + "TRAIT_ROLE": true, + "TRAIT_APP": true, + } + + for i, r := range c.Resources { + prefix := fmt.Sprintf("resources[%d]", i) + if r.Type != "" { + prefix = fmt.Sprintf("resources[%d] (%s)", i, r.Type) + } + + switch { + case r.Type == "": + errs = append(errs, fmt.Sprintf("%s: type is required", prefix)) + case resourceTypes[r.Type]: + errs = append(errs, fmt.Sprintf("%s: duplicate resource type", prefix)) + default: + resourceTypes[r.Type] = true + } + + if !validTraits[r.Trait] { + errs = append(errs, fmt.Sprintf("%s: invalid trait %q", prefix, r.Trait)) + } + + if r.Trait == "TRAIT_USER" { + hasUser = true + } + + if r.List.Endpoint == "" { + errs = append(errs, fmt.Sprintf("%s: list.endpoint is required", prefix)) + } + + if r.Fields.ID == "" { + errs = append(errs, fmt.Sprintf("%s: fields.id is required", prefix)) + } + + if r.List.Pagination != nil { + pag := r.List.Pagination + validPagTypes := map[string]bool{"cursor": true, "offset": true, "page": true, "link": true} + if !validPagTypes[pag.Type] { + errs = append(errs, fmt.Sprintf("%s: invalid pagination type %q", prefix, pag.Type)) + } + if pag.Type == "cursor" && (pag.CursorParam == "" || pag.CursorPath == "") { + errs = append(errs, fmt.Sprintf("%s: cursor pagination requires cursor_param and cursor_path", prefix)) + } + if pag.Type == "offset" && (pag.OffsetParam == "" || pag.LimitParam == "") { + errs = append(errs, fmt.Sprintf("%s: offset pagination requires offset_param and limit_param", prefix)) + } + } + + for j, e := range r.Entitlements { + eprefix := fmt.Sprintf("%s.entitlements[%d]", prefix, j) + if e.ID != "" { + eprefix = fmt.Sprintf("%s.entitlements[%d] (%s)", prefix, j, e.ID) + } + + if e.ID == "" { + errs = append(errs, fmt.Sprintf("%s: id is required", eprefix)) + } + + if len(e.GrantableTo) == 0 { + errs = append(errs, fmt.Sprintf("%s: grantable_to is required", eprefix)) + } + + if e.Grants != nil { + if e.Grants.Endpoint == "" { + errs = append(errs, fmt.Sprintf("%s.grants: endpoint is required", eprefix)) + } + if e.Grants.PrincipalIDPath == "" { + errs = append(errs, fmt.Sprintf("%s.grants: principal_id_path is required", eprefix)) + } + if e.Grants.PrincipalType == "" { + errs = append(errs, fmt.Sprintf("%s.grants: principal_type is required", eprefix)) + } + } + + if e.Provisioning != nil { + if e.Provisioning.Grant != nil { + if e.Provisioning.Grant.Endpoint == "" { + errs = append(errs, fmt.Sprintf("%s.provisioning.grant: endpoint is required", eprefix)) + } + if e.Provisioning.Grant.Method == "" { + errs = append(errs, fmt.Sprintf("%s.provisioning.grant: method is required", eprefix)) + } + } + if e.Provisioning.Revoke != nil { + if e.Provisioning.Revoke.Endpoint == "" { + errs = append(errs, fmt.Sprintf("%s.provisioning.revoke: endpoint is required", eprefix)) + } + if e.Provisioning.Revoke.Method == "" { + errs = append(errs, fmt.Sprintf("%s.provisioning.revoke: method is required", eprefix)) + } + } + } + } + } + + if !hasUser && len(c.Resources) > 0 { + errs = append(errs, "at least one resource with TRAIT_USER is required") + } + + if len(errs) > 0 { + msg := fmt.Sprintf("validation failed (%d errors):", len(errs)) + for _, e := range errs { + msg += fmt.Sprintf("\n - %s", e) + } + return fmt.Errorf("%s", msg) + } + + return nil +} diff --git a/cmd/cone/get_drop_task.go b/cmd/cone/get_drop_task.go index c51c0c5f..9c024744 100644 --- a/cmd/cone/get_drop_task.go +++ b/cmd/cone/get_drop_task.go @@ -24,7 +24,11 @@ const durationInputTip = "We accept a sequence of decimal numbers, each with opt "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 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." + +// Error message variants (lowercase, no trailing punctuation per Go style). +const errJustificationRequired = "justification is required when requesting access to an entitlement" +const errMultipleUsersSelectOne = "this app has multiple users, please select one" func getCmd() *cobra.Command { cmd := &cobra.Command{ @@ -135,7 +139,7 @@ func getValidJustification(ctx context.Context, v *viper.Viper, justification st if v.GetBool(nonInteractiveFlag) { pterm.Info.Println(justificationInputTip) - return "", errors.New(justificationWarningMessage) + return "", errors.New(errJustificationRequired) } justificationInput, err := output.GetValidInput[string](ctx, justification, JustificationValidator{}) if err != nil { @@ -450,7 +454,7 @@ func getAppUserId(ctx context.Context, c client.C1Client, v *viper.Viper, appId, return client.StringFromPtr(appUsers[0].ID), nil default: if v.GetBool(nonInteractiveFlag) { - return "", errors.New(appUserMultipleUsersWarningMessage) + return "", errors.New(errMultipleUsersSelectOne) } output.InputNeeded.Println(appUserMultipleUsersWarningMessage) diff --git a/cmd/cone/main.go b/cmd/cone/main.go index dc264b2e..b5a9ba2a 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(connectorCmd()) err = cliCmd.ExecuteContext(ctx) if err != nil { diff --git a/cmd/cone/token.go b/cmd/cone/token.go index 3e3afa65..164e923d 100644 --- a/cmd/cone/token.go +++ b/cmd/cone/token.go @@ -60,7 +60,6 @@ func tokenRun(cmd *cobra.Command, args []string) error { } if v.GetBool(rawTokenFlag) { - //nolint:forbidigo // We want to raw-print the bearer if this flag is included fmt.Println(tokenObj.AccessToken) return nil } diff --git a/go.mod b/go.mod index 77724db2..41cbab1e 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/toqueteos/webbrowser v1.2.0 github.com/xhit/go-str2duration/v2 v2.1.0 golang.org/x/sync v0.15.0 + golang.org/x/term v0.32.0 ) require ( @@ -39,12 +40,11 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.39.0 // indirect - golang.org/x/term v0.32.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect ) require ( - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -60,5 +60,5 @@ require ( golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.26.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 ) diff --git a/pkg/client/entitlement.go b/pkg/client/entitlement.go index d57a7246..f9632413 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, @@ -157,9 +157,13 @@ func (c *client) SearchEntitlements(ctx context.Context, filter *SearchEntitleme // Iterate over the expandable objects and convert them to the final response rv := make([]*EntitlementWithBindings, 0, len(list)) for _, v := range expandableList { + // Skip entries with nil AppEntitlementView or AppEntitlement + if v.AppEntitlementView == nil || v.AppEntitlementView.AppEntitlement == nil { + continue + } 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/scaffold/scaffold.go b/pkg/scaffold/scaffold.go new file mode 100644 index 00000000..fb808f40 --- /dev/null +++ b/pkg/scaffold/scaffold.go @@ -0,0 +1,1979 @@ +// Package scaffold provides templates for generating new connector projects. +package scaffold + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +// Config holds configuration for generating a new connector project. +type Config struct { + // Name is the connector name (e.g., "my-app") + Name string + // ModulePath is the Go module path (e.g., "github.com/myorg/baton-my-app") + ModulePath string + // OutputDir is where the project will be created + OutputDir string + // Description is a brief description of the connector + Description string +} + +// Generate creates a new connector project from the standard template. +func Generate(cfg *Config) error { + if cfg.Name == "" { + return fmt.Errorf("scaffold: connector name is required") + } + if cfg.ModulePath == "" { + cfg.ModulePath = fmt.Sprintf("github.com/conductorone/baton-%s", cfg.Name) + } + if cfg.OutputDir == "" { + cfg.OutputDir = fmt.Sprintf("baton-%s", cfg.Name) + } + if cfg.Description == "" { + cfg.Description = fmt.Sprintf("ConductorOne connector for %s", cfg.Name) + } + + // Create output directory + if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + return fmt.Errorf("scaffold: failed to create output directory: %w", err) + } + + // Generate all template files + for _, tf := range templateFiles { + if err := generateFile(cfg, tf); err != nil { + return fmt.Errorf("scaffold: failed to generate %s: %w", tf.Path, err) + } + } + + return nil +} + +// templateFile represents a file to be generated. +type templateFile struct { + Path string + Template string + Mode os.FileMode +} + +// generateFile generates a single file from a template. +func generateFile(cfg *Config, tf templateFile) error { + // Parse template + tmpl, err := template.New(tf.Path).Parse(tf.Template) + if err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + // Expand path template variables + expandedPath := strings.ReplaceAll(tf.Path, "{{.Name}}", cfg.Name) + + // Create directory structure + fullPath := filepath.Join(cfg.OutputDir, expandedPath) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Create file + mode := tf.Mode + if mode == 0 { + mode = 0644 + } + f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer f.Close() + + // Execute template + data := map[string]string{ + "Name": cfg.Name, + "NameTitle": toTitleCase(cfg.Name), + "NamePascal": toPascalCase(cfg.Name), + "ModulePath": cfg.ModulePath, + "Description": cfg.Description, + } + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + return nil +} + +// toPascalCase converts a kebab-case string to PascalCase. +func toPascalCase(s string) string { + parts := strings.Split(s, "-") + for i, p := range parts { + if len(p) > 0 { + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + } + return strings.Join(parts, "") +} + +// toTitleCase converts a kebab-case string to Title Case. +func toTitleCase(s string) string { + parts := strings.Split(s, "-") + for i, p := range parts { + if len(p) > 0 { + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + } + return strings.Join(parts, " ") +} + +// templateFiles contains all the files to generate for a new connector. +// These templates use baton-sdk v0.4.7+ patterns with config.DefineConfiguration. +var templateFiles = []templateFile{ + { + Path: "go.mod", + Template: `module {{.ModulePath}} + +go 1.23 + +require ( + github.com/conductorone/baton-sdk v0.7.1 + github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 + go.uber.org/zap v1.27.0 +) +`, + }, + { + Path: "main.go", + Template: `package main + +import ( + "context" + "fmt" + "os" + + "{{.ModulePath}}/pkg/connector" + configSdk "github.com/conductorone/baton-sdk/pkg/config" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-sdk/pkg/field" + "github.com/conductorone/baton-sdk/pkg/types" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +var version = "dev" + +// Config holds the connector configuration. +// It implements field.Configurable to work with the SDK's configuration system. +// +// Required permissions for sync operations: +// - Read access to {{.NameTitle}} users, groups, and roles +// +// Required permissions for provisioning operations: +// - Write access to create/modify/delete users and group memberships +type Config struct { + // BaseURL is the API base URL. Override for testing against mocks. + BaseURL string ` + "`" + `mapstructure:"base-url"` + "`" + ` + // Insecure skips TLS certificate verification. ONLY use for testing. + Insecure bool ` + "`" + `mapstructure:"insecure"` + "`" + ` + // Add connector-specific fields here as needed. + // Example: APIKey string ` + "`" + `mapstructure:"api-key"` + "`" + ` +} + +// Implement field.Configurable interface. +// These methods allow the SDK to read configuration values. +// Each method should return the appropriate value for the given key. +func (c *Config) GetString(key string) string { + switch key { + case "base-url": + return c.BaseURL + default: + return "" + } +} + +func (c *Config) GetBool(key string) bool { + switch key { + case "insecure": + return c.Insecure + default: + return false + } +} + +func (c *Config) GetInt(key string) int { return 0 } +func (c *Config) GetStringSlice(key string) []string { return nil } +func (c *Config) GetStringMap(key string) map[string]any { return nil } + +// Configuration fields for the connector. +// These define CLI flags and environment variables. +var configFields = []field.SchemaField{ + // Testability: Allow overriding base URL for mock servers + field.StringField( + "base-url", + field.WithDescription("Base URL for the {{.NameTitle}} API (override for testing)"), + field.WithDefaultValue("https://api.example.com"), // TODO: Set your API's default URL + ), + // Testability: Allow skipping TLS verification for self-signed certs + field.BoolField( + "insecure", + field.WithDescription("Skip TLS certificate verification (for testing only - DO NOT USE IN PRODUCTION)"), + field.WithDefaultValue(false), + ), + // TODO: Add your connector-specific fields here, e.g.: + // field.StringField("api-key", field.WithRequired(true), field.WithDescription("API key for authentication")), +} + +// ConfigSchema is the configuration schema for the connector. +var ConfigSchema = field.NewConfiguration( + configFields, + field.WithConnectorDisplayName("{{.NameTitle}}"), +) + +func main() { + ctx := context.Background() + + _, cmd, err := configSdk.DefineConfiguration( + ctx, + "baton-{{.Name}}", + getConnector, + ConfigSchema, + ) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + cmd.Version = version + + err = cmd.Execute() + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } +} + +func getConnector(ctx context.Context, cfg *Config) (types.ConnectorServer, error) { + l := ctxzap.Extract(ctx) + + // Log warning if insecure mode is enabled + if cfg.Insecure { + l.Warn("baton-{{.Name}}: TLS certificate verification disabled - DO NOT USE IN PRODUCTION") + } + + cb, err := connector.New(ctx, cfg.BaseURL, cfg.Insecure) + if err != nil { + l.Error("baton-{{.Name}}: error creating connector", zap.Error(err)) + return nil, fmt.Errorf("baton-{{.Name}}: failed to create connector: %w", err) + } + + // IMPORTANT: connectorbuilder.NewConnector wraps your connector with SDK infrastructure. + // This is REQUIRED - without it, the connector won't function. + // The wrapper provides: gRPC server, sync orchestration, pagination handling. + c, err := connectorbuilder.NewConnector(ctx, cb) + if err != nil { + l.Error("baton-{{.Name}}: error wrapping connector", zap.Error(err)) + return nil, fmt.Errorf("baton-{{.Name}}: failed to initialize connector (connectorbuilder.NewConnector failed): %w", err) + } + + return c, nil +} +`, + }, + { + Path: "pkg/connector/connector.go", + Template: `package connector + +import ( + "context" + "crypto/tls" + "io" + "net/http" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +// Connector implements the {{.Name}} connector. +type Connector struct { + baseURL string + httpClient *http.Client + // TODO: Add API client or other state here. + // Example: client *{{.NamePascal}}Client +} + +// ResourceSyncers returns a ResourceSyncer for each resource type that should be synced. +// +// Resource types: +// - user: Users from {{.NameTitle}} (principals that receive grants) +// - group: Groups with "member" entitlement +// - role: Roles with "assigned" entitlement +// +// The three fundamental resource types are: +// 1. Users - principals that can be granted access +// 2. Groups - collections of users with membership entitlement +// 3. Roles - permissions that can be assigned to users +func (c *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncer { + return []connectorbuilder.ResourceSyncer{ + newUserBuilder(c), + newGroupBuilder(c), + newRoleBuilder(c), + } +} + +// Asset takes an input AssetRef and attempts to fetch it. +// Most connectors don't need to implement this. +func (c *Connector) Asset(ctx context.Context, asset *v2.AssetRef) (string, io.ReadCloser, error) { + return "", nil, nil +} + +// Metadata returns metadata about the connector. +func (c *Connector) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { + return &v2.ConnectorMetadata{ + DisplayName: "{{.NameTitle}}", + Description: "{{.Description}}", + }, nil +} + +// Validate is called to ensure that the connector is properly configured. +// This runs before every sync to fail fast on bad credentials. +// +// Required permissions: +// - Read access to {{.NameTitle}} API (basic endpoint like /users or /me) +func (c *Connector) Validate(ctx context.Context) (annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: validating connection") + + // TODO: Implement validation - test API connection + // Example: + // _, err := c.client.GetCurrentUser(ctx) + // if err != nil { + // return nil, fmt.Errorf("baton-{{.Name}}: validation failed: %w", err) + // } + + l.Info("baton-{{.Name}}: connection validated") + return nil, nil +} + +// New returns a new instance of the connector. +// +// Parameters: +// - baseURL: API base URL (can be overridden for testing) +// - insecure: Skip TLS verification (for testing with self-signed certs) +func New(ctx context.Context, baseURL string, insecure bool) (*Connector, error) { + l := ctxzap.Extract(ctx) + + // Configure HTTP client with optional insecure TLS + httpClient := &http.Client{} + if insecure { + l.Warn("baton-{{.Name}}: TLS certificate verification disabled") + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // Intentional for testing + }, + } + } + + l.Info("baton-{{.Name}}: creating connector", + zap.String("base_url", baseURL), + zap.Bool("insecure", insecure), + ) + + // TODO: Create API client + // Example: + // client, err := NewClient(baseURL, httpClient) + // if err != nil { + // return nil, fmt.Errorf("baton-{{.Name}}: failed to create client: %w", err) + // } + + return &Connector{ + baseURL: baseURL, + httpClient: httpClient, + }, nil +} +`, + }, + { + Path: "pkg/connector/resource_types.go", + Template: `package connector + +import ( + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" +) + +// Resource type definitions for {{.NameTitle}}. +// Each resource type maps to an entity in the target system. + +// userResourceType defines the user resource type. +// Users are principals that can receive grants to entitlements. +var userResourceType = &v2.ResourceType{ + Id: "user", + DisplayName: "User", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}, +} + +// groupResourceType defines the group resource type. +// Groups have a "member" entitlement that users can be granted. +var groupResourceType = &v2.ResourceType{ + Id: "group", + DisplayName: "Group", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, +} + +// roleResourceType defines the role resource type. +// Roles have an "assigned" entitlement that users can be granted. +var roleResourceType = &v2.ResourceType{ + Id: "role", + DisplayName: "Role", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}, +} +`, + }, + { + Path: "pkg/connector/users.go", + Template: `package connector + +import ( + "context" + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" +) + +type userBuilder struct { + conn *Connector +} + +func (u *userBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return userResourceType +} + +// List returns all the users from the upstream service as resource objects. +// +// Required permissions: +// - Read access to users in {{.NameTitle}} +func (u *userBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: listing users") + + // TODO: Implement user listing with pagination + // Example: + // + // page := "" + // if pToken != nil && pToken.Token != "" { + // page = pToken.Token + // } + // + // users, nextPage, err := u.conn.client.ListUsers(ctx, page, 100) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to list users: %w", err) + // } + // + // var rv []*v2.Resource + // for _, user := range users { + // // Check context for cancellation in loops + // select { + // case <-ctx.Done(): + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: context cancelled: %w", ctx.Err()) + // default: + // } + // + // displayName := user.Name + // if displayName == "" { + // displayName = user.Email // Fall back to email if no name + // } + // + // resource, err := rs.NewUserResource( + // displayName, + // userResourceType, + // user.ID, + // []rs.UserTraitOption{ + // rs.WithEmail(user.Email, true), + // rs.WithUserLogin(user.Username), + // rs.WithStatus(v2.UserTrait_Status_STATUS_ENABLED), + // }, + // // ExternalId is CRITICAL for provisioning - stores native identifier + // rs.WithExternalID(&v2.ExternalId{ + // Id: user.ID, + // Link: fmt.Sprintf("%s/users/%s", u.conn.baseURL, user.ID), + // }), + // ) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to create user resource: %w", err) + // } + // rv = append(rv, resource) + // } + // + // l.Info("baton-{{.Name}}: listed users", zap.Int("count", len(rv))) + // return rv, nextPage, nil, nil + + // Placeholder - remove after implementing + _ = rs.NewUserResource + _ = fmt.Sprintf + l.Info("baton-{{.Name}}: user listing not implemented yet") + return nil, "", nil, nil +} + +// Entitlements always returns an empty slice for users. +// Users are principals that receive grants, not resources with grantable permissions. +func (u *userBuilder) Entitlements(_ context.Context, resource *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { + return nil, "", nil, nil +} + +// Grants always returns an empty slice for users. +// Grants flow from entitlements to users, not from users. +func (u *userBuilder) Grants(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { + return nil, "", nil, nil +} + +// ============================================================================= +// PROVISIONING: Create/Delete Users (ResourceManager interface) +// ============================================================================= +// Uncomment and implement these methods to support user lifecycle management. +// +// func (u *userBuilder) Create(ctx context.Context, resource *v2.Resource) (*v2.Resource, annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// l.Info("baton-{{.Name}}: creating user") +// +// // Get user traits from the resource +// userTrait, err := rs.GetUserTrait(resource) +// if err != nil { +// return nil, nil, fmt.Errorf("baton-{{.Name}}: failed to get user trait: %w", err) +// } +// +// // TODO: Create user in upstream system +// // newUser, err := u.conn.client.CreateUser(ctx, &CreateUserRequest{ +// // Email: userTrait.GetEmail().GetAddress(), +// // Username: userTrait.GetLogin(), +// // }) +// // if err != nil { +// // return nil, nil, fmt.Errorf("baton-{{.Name}}: failed to create user: %w", err) +// // } +// +// // Return the created resource with its new ID +// // return rs.NewUserResource(newUser.Name, userResourceType, newUser.ID, ...) +// return nil, nil, fmt.Errorf("baton-{{.Name}}: user creation not implemented") +// } +// +// func (u *userBuilder) Delete(ctx context.Context, resourceId *v2.ResourceId) (annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// l.Info("baton-{{.Name}}: deleting user", zap.String("id", resourceId.Resource)) +// +// // TODO: Delete user from upstream system +// // err := u.conn.client.DeleteUser(ctx, resourceId.Resource) +// // if err != nil { +// // return nil, fmt.Errorf("baton-{{.Name}}: failed to delete user: %w", err) +// // } +// // return nil, nil +// return nil, fmt.Errorf("baton-{{.Name}}: user deletion not implemented") +// } + +func newUserBuilder(conn *Connector) *userBuilder { + return &userBuilder{conn: conn} +} +`, + }, + { + Path: "pkg/connector/groups.go", + Template: `package connector + +import ( + "context" + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +const memberEntitlement = "member" + +type groupBuilder struct { + conn *Connector +} + +func (g *groupBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return groupResourceType +} + +// List returns all groups from the upstream service. +// +// Required permissions: +// - Read access to groups in {{.NameTitle}} +func (g *groupBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: listing groups") + + // TODO: Implement group listing with pagination + // Example: + // + // page := "" + // if pToken != nil && pToken.Token != "" { + // page = pToken.Token + // } + // + // groups, nextPage, err := g.conn.client.ListGroups(ctx, page, 100) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to list groups: %w", err) + // } + // + // var rv []*v2.Resource + // for _, group := range groups { + // resource, err := rs.NewGroupResource( + // group.Name, + // groupResourceType, + // group.ID, + // []rs.GroupTraitOption{ + // rs.WithGroupProfile(map[string]interface{}{ + // "description": group.Description, + // }), + // }, + // rs.WithExternalID(&v2.ExternalId{Id: group.ID}), + // ) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to create group resource: %w", err) + // } + // rv = append(rv, resource) + // } + // + // l.Info("baton-{{.Name}}: listed groups", zap.Int("count", len(rv))) + // return rv, nextPage, nil, nil + + _ = rs.NewGroupResource + _ = fmt.Sprintf + l.Info("baton-{{.Name}}: group listing not implemented yet") + return nil, "", nil, nil +} + +// Entitlements returns the "member" entitlement for the group. +// This entitlement can be granted to users to make them members of the group. +func (g *groupBuilder) Entitlements(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { + // Create the "member" entitlement for this group + entitlement := ent.NewAssignmentEntitlement( + resource, + memberEntitlement, + ent.WithGrantableTo(userResourceType), + ent.WithDisplayName(fmt.Sprintf("%s Group Member", resource.DisplayName)), + ent.WithDescription(fmt.Sprintf("Member of the %s group", resource.DisplayName)), + ) + + return []*v2.Entitlement{entitlement}, "", nil, nil +} + +// Grants returns all users who are members of this group. +// +// Required permissions: +// - Read access to group memberships in {{.NameTitle}} +func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: listing group members", zap.String("group_id", resource.Id.Resource)) + + // TODO: Implement group membership listing + // Example: + // + // groupID := resource.Id.Resource + // + // page := "" + // if pToken != nil && pToken.Token != "" { + // page = pToken.Token + // } + // + // members, nextPage, err := g.conn.client.ListGroupMembers(ctx, groupID, page, 100) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to list group members: %w", err) + // } + // + // var rv []*v2.Grant + // for _, member := range members { + // grant := grant.NewGrant( + // resource, + // memberEntitlement, + // &v2.ResourceId{ + // ResourceType: userResourceType.Id, + // Resource: member.UserID, + // }, + // ) + // rv = append(rv, grant) + // } + // + // l.Info("baton-{{.Name}}: listed group members", zap.Int("count", len(rv))) + // return rv, nextPage, nil, nil + + _ = grant.NewGrant + l.Info("baton-{{.Name}}: group grants not implemented yet") + return nil, "", nil, nil +} + +// ============================================================================= +// PROVISIONING: Grant/Revoke group membership (ResourceProvisioner interface) +// ============================================================================= +// Uncomment to support group membership provisioning. +// +// ENTITY SOURCE RULE (prevents the #1 connector bug pattern): +// - principal = WHO is getting access (the user receiving the grant) +// - entitlement = WHAT access they're getting (the group/permission) +// Verify each ID comes from the correct entity before calling API. +// +// func (g *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// +// // ENTITY SOURCE: group ID comes from entitlement (what), user ID from principal (who) +// groupID := entitlement.Resource.Id.Resource // from entitlement +// userID := principal.Id.Resource // from principal +// +// l.Info("baton-{{.Name}}: granting group membership", zap.String("group", groupID), zap.String("user", userID)) +// +// err := g.conn.client.AddGroupMember(ctx, groupID, userID) +// if err != nil { +// // IDEMPOTENCY: "already exists" is success, not failure +// if isAlreadyMemberError(err) { +// l.Debug("baton-{{.Name}}: user already member of group") +// return nil, nil +// } +// return nil, fmt.Errorf("baton-{{.Name}}: failed to grant membership: %w", err) +// } +// return nil, nil +// } +// +// func (g *groupBuilder) Revoke(ctx context.Context, grantToRevoke *v2.Grant) (annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// +// // ENTITY SOURCE: IDs come from the grant being revoked +// groupID := grantToRevoke.Entitlement.Resource.Id.Resource +// userID := grantToRevoke.Principal.Id.Resource +// +// l.Info("baton-{{.Name}}: revoking group membership", zap.String("group", groupID), zap.String("user", userID)) +// +// err := g.conn.client.RemoveGroupMember(ctx, groupID, userID) +// if err != nil { +// // IDEMPOTENCY: "not found" is success - already revoked +// if isNotFoundError(err) { +// l.Debug("baton-{{.Name}}: user not member of group (already revoked)") +// return nil, nil +// } +// return nil, fmt.Errorf("baton-{{.Name}}: failed to revoke membership: %w", err) +// } +// return nil, nil +// } +// +// // Helper functions for idempotency checks - implement based on your API's error responses +// // func isAlreadyMemberError(err error) bool { return strings.Contains(err.Error(), "already") } +// // func isNotFoundError(err error) bool { return strings.Contains(err.Error(), "not found") } + +func newGroupBuilder(conn *Connector) *groupBuilder { + return &groupBuilder{conn: conn} +} +`, + }, + { + Path: "pkg/connector/roles.go", + Template: `package connector + +import ( + "context" + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +const assignedEntitlement = "assigned" + +type roleBuilder struct { + conn *Connector +} + +func (r *roleBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return roleResourceType +} + +// List returns all roles from the upstream service. +// +// Required permissions: +// - Read access to roles in {{.NameTitle}} +func (r *roleBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: listing roles") + + // TODO: Implement role listing with pagination + // Example: + // + // page := "" + // if pToken != nil && pToken.Token != "" { + // page = pToken.Token + // } + // + // roles, nextPage, err := r.conn.client.ListRoles(ctx, page, 100) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to list roles: %w", err) + // } + // + // var rv []*v2.Resource + // for _, role := range roles { + // resource, err := rs.NewRoleResource( + // role.Name, + // roleResourceType, + // role.ID, + // []rs.RoleTraitOption{ + // rs.WithRoleProfile(map[string]interface{}{ + // "description": role.Description, + // "permissions": role.Permissions, + // }), + // }, + // rs.WithExternalID(&v2.ExternalId{Id: role.ID}), + // ) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to create role resource: %w", err) + // } + // rv = append(rv, resource) + // } + // + // l.Info("baton-{{.Name}}: listed roles", zap.Int("count", len(rv))) + // return rv, nextPage, nil, nil + + _ = rs.NewRoleResource + _ = fmt.Sprintf + l.Info("baton-{{.Name}}: role listing not implemented yet") + return nil, "", nil, nil +} + +// Entitlements returns the "assigned" entitlement for the role. +// This entitlement can be granted to users to assign them the role. +func (r *roleBuilder) Entitlements(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { + // Create the "assigned" entitlement for this role + entitlement := ent.NewAssignmentEntitlement( + resource, + assignedEntitlement, + ent.WithGrantableTo(userResourceType), + ent.WithDisplayName(fmt.Sprintf("%s Role", resource.DisplayName)), + ent.WithDescription(fmt.Sprintf("Assigned the %s role", resource.DisplayName)), + ) + + return []*v2.Entitlement{entitlement}, "", nil, nil +} + +// Grants returns all users who are assigned this role. +// +// Required permissions: +// - Read access to role assignments in {{.NameTitle}} +func (r *roleBuilder) Grants(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: listing role assignments", zap.String("role_id", resource.Id.Resource)) + + // TODO: Implement role assignment listing + // Example: + // + // roleID := resource.Id.Resource + // + // page := "" + // if pToken != nil && pToken.Token != "" { + // page = pToken.Token + // } + // + // assignments, nextPage, err := r.conn.client.ListRoleAssignments(ctx, roleID, page, 100) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to list role assignments: %w", err) + // } + // + // var rv []*v2.Grant + // for _, assignment := range assignments { + // grant := grant.NewGrant( + // resource, + // assignedEntitlement, + // &v2.ResourceId{ + // ResourceType: userResourceType.Id, + // Resource: assignment.UserID, + // }, + // ) + // rv = append(rv, grant) + // } + // + // l.Info("baton-{{.Name}}: listed role assignments", zap.Int("count", len(rv))) + // return rv, nextPage, nil, nil + + _ = grant.NewGrant + l.Info("baton-{{.Name}}: role grants not implemented yet") + return nil, "", nil, nil +} + +// ============================================================================= +// PROVISIONING: Grant/Revoke role assignment (ResourceProvisioner interface) +// ============================================================================= +// Uncomment to support role assignment provisioning. +// +// ENTITY SOURCE RULE (prevents the #1 connector bug pattern): +// - principal = WHO is getting access (the user receiving the role) +// - entitlement = WHAT access they're getting (the role) +// Verify each ID comes from the correct entity before calling API. +// +// func (r *roleBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// +// // ENTITY SOURCE: role ID comes from entitlement, user ID from principal +// roleID := entitlement.Resource.Id.Resource // from entitlement +// userID := principal.Id.Resource // from principal +// +// l.Info("baton-{{.Name}}: granting role", zap.String("role", roleID), zap.String("user", userID)) +// +// err := r.conn.client.AssignRole(ctx, roleID, userID) +// if err != nil { +// // IDEMPOTENCY: "already assigned" is success +// if isAlreadyAssignedError(err) { +// return nil, nil +// } +// return nil, fmt.Errorf("baton-{{.Name}}: failed to grant role: %w", err) +// } +// return nil, nil +// } +// +// func (r *roleBuilder) Revoke(ctx context.Context, grantToRevoke *v2.Grant) (annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// +// roleID := grantToRevoke.Entitlement.Resource.Id.Resource +// userID := grantToRevoke.Principal.Id.Resource +// +// l.Info("baton-{{.Name}}: revoking role", zap.String("role", roleID), zap.String("user", userID)) +// +// err := r.conn.client.UnassignRole(ctx, roleID, userID) +// if err != nil { +// // IDEMPOTENCY: "not found" is success - already revoked +// if isNotFoundError(err) { +// return nil, nil +// } +// return nil, fmt.Errorf("baton-{{.Name}}: failed to revoke role: %w", err) +// } +// return nil, nil +// } + +func newRoleBuilder(conn *Connector) *roleBuilder { + return &roleBuilder{conn: conn} +} +`, + }, + { + Path: "pkg/connector/actions.go", + Template: `package connector + +import ( + "context" + "fmt" + + config "github.com/conductorone/baton-sdk/pb/c1/config/v1" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/actions" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/structpb" +) + +// ============================================================================= +// BATON ACTIONS: Custom operations exposed to ConductorOne +// ============================================================================= +// +// Actions are arbitrary operations your connector can perform. Unlike Grant/Revoke +// (which modify access) or Create/Delete (which manage resources), Actions are +// general-purpose operations that ConductorOne can trigger. +// +// Common action types: +// - ACTION_TYPE_ACCOUNT_ENABLE / ACTION_TYPE_ACCOUNT_DISABLE +// - ACTION_TYPE_ACCOUNT_UPDATE_PROFILE +// - Custom operations specific to your system +// +// To enable actions, uncomment GlobalActions and the action handlers below. + +// Example: Disable account action schema +var disableAccountAction = &v2.BatonActionSchema{ + Name: "disableAccount", + Arguments: []*config.Field{ + { + Name: "accountId", + DisplayName: "Account ID", + Description: "The ID of the account to disable", + Field: &config.Field_StringField{}, + IsRequired: true, + }, + }, + ReturnTypes: []*config.Field{ + { + Name: "success", + DisplayName: "Success", + Field: &config.Field_BoolField{}, + }, + }, + ActionType: []v2.ActionType{ + v2.ActionType_ACTION_TYPE_ACCOUNT, + v2.ActionType_ACTION_TYPE_ACCOUNT_DISABLE, + }, +} + +// Example: Enable account action schema +var enableAccountAction = &v2.BatonActionSchema{ + Name: "enableAccount", + Arguments: []*config.Field{ + { + Name: "accountId", + DisplayName: "Account ID", + Description: "The ID of the account to enable", + Field: &config.Field_StringField{}, + IsRequired: true, + }, + }, + ReturnTypes: []*config.Field{ + { + Name: "success", + DisplayName: "Success", + Field: &config.Field_BoolField{}, + }, + }, + ActionType: []v2.ActionType{ + v2.ActionType_ACTION_TYPE_ACCOUNT, + v2.ActionType_ACTION_TYPE_ACCOUNT_ENABLE, + }, +} + +// GlobalActions registers custom actions with the SDK. +// Uncomment to enable actions. +// +// func (c *Connector) GlobalActions(ctx context.Context, registry actions.ActionRegistry) error { +// if err := registry.Register(ctx, disableAccountAction, c.disableAccount); err != nil { +// return err +// } +// if err := registry.Register(ctx, enableAccountAction, c.enableAccount); err != nil { +// return err +// } +// return nil +// } + +func (c *Connector) disableAccount(ctx context.Context, args *structpb.Struct) (*structpb.Struct, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + accountId, ok := args.Fields["accountId"] + if !ok { + return nil, nil, fmt.Errorf("missing required argument accountId") + } + + l.Info("baton-{{.Name}}: disabling account", zap.String("accountId", accountId.GetStringValue())) + + // TODO: Implement account disabling in upstream system + // err := c.client.DisableUser(ctx, accountId.GetStringValue()) + // if err != nil { + // return nil, nil, fmt.Errorf("baton-{{.Name}}: failed to disable account: %w", err) + // } + + response := &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "success": structpb.NewBoolValue(true), + }, + } + return response, nil, nil +} + +func (c *Connector) enableAccount(ctx context.Context, args *structpb.Struct) (*structpb.Struct, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + accountId, ok := args.Fields["accountId"] + if !ok { + return nil, nil, fmt.Errorf("missing required argument accountId") + } + + l.Info("baton-{{.Name}}: enabling account", zap.String("accountId", accountId.GetStringValue())) + + // TODO: Implement account enabling in upstream system + // err := c.client.EnableUser(ctx, accountId.GetStringValue()) + // if err != nil { + // return nil, nil, fmt.Errorf("baton-{{.Name}}: failed to enable account: %w", err) + // } + + response := &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "success": structpb.NewBoolValue(true), + }, + } + return response, nil, nil +} + +// Ensure imports are used (remove after implementing) +var _ = actions.ActionRegistry(nil) +var _ = disableAccountAction +var _ = enableAccountAction +`, + }, + { + Path: ".gitignore", + Template: `# Binaries +baton-{{.Name}} +*.exe +*.dll +*.so +*.dylib + +# Test coverage +*.out +coverage.html + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +dist/ +c1z/ +*.c1z + +# Environment +.env +.env.local +`, + }, + { + Path: ".env.example", + Template: `# {{.NameTitle}} Connector Configuration +# Copy this file to .env and fill in your values +# All variables use the BATON_ prefix + +# ============================================================================= +# Target System Authentication +# ============================================================================= + +# API key for {{.NameTitle}} (if using API key auth) +# BATON_API_KEY=your-api-key-here + +# Bearer token for {{.NameTitle}} (if using bearer auth) +# BATON_BEARER_TOKEN=your-bearer-token-here + +# ============================================================================= +# ConductorOne Authentication (for daemon mode) +# ============================================================================= + +# Client credentials for ConductorOne integration +# Get these from ConductorOne admin console +# BATON_CLIENT_ID=your-client-id +# BATON_CLIENT_SECRET=your-client-secret + +# ============================================================================= +# Testing and Development +# ============================================================================= + +# Override base URL (for testing against mocks) +# BATON_BASE_URL=http://localhost:8089 + +# Skip TLS verification (ONLY for local testing with self-signed certs) +# BATON_INSECURE=true + +# ============================================================================= +# Logging and Observability +# ============================================================================= + +# Log level: debug, info, warn, error +BATON_LOG_LEVEL=info + +# Log format: json, console +BATON_LOG_FORMAT=json + +# OpenTelemetry collector endpoint (optional) +# BATON_OTEL_COLLECTOR_ENDPOINT=http://localhost:4317 +`, + }, + { + Path: "README.md", + Template: `# baton-{{.Name}} + +{{.Description}} + +## Prerequisites + +- Go 1.23+ + +## Installation + +` + "```" + `bash +go install {{.ModulePath}}@latest +` + "```" + ` + +## Configuration + +Copy ` + "`.env.example`" + ` to ` + "`.env`" + ` and fill in your values: + +` + "```" + `bash +cp .env.example .env +# Edit .env with your credentials +` + "```" + ` + +Or pass configuration via CLI flags: + +` + "```" + `bash +baton-{{.Name}} --api-key=your-key +` + "```" + ` + +See all options with ` + "`baton-{{.Name}} --help`" + `. + +## Usage + +` + "```" + `bash +# Run sync (outputs to sync.c1z) +baton-{{.Name}} + +# Run with specific output file +baton-{{.Name}} -f output.c1z + +# See all options +baton-{{.Name}} --help +` + "```" + ` + +## Development + +` + "```" + `bash +# Build +make build + +# Format, vet, and build +make check + +# Run all validations (tidy, fmt, vet, lint, test, build) +make all + +# Run tests +make test + +# Run tests with coverage +make test-cover + +# Format code +make fmt + +# Run linter +make lint +` + "```" + ` + +## Resources + +This connector syncs the following resources: + +| Resource Type | Description | +|---------------|-------------| +| User | {{.NameTitle}} users | + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: ` + "`go test ./...`" + ` +5. Submit a pull request +`, + }, + { + Path: "Makefile", + Template: `.PHONY: build test test-mock clean lint fmt vet tidy check all + +BINARY_NAME=baton-{{.Name}} +MOCK_PORT?=8089 + +# Build the connector binary +build: + go build -o $(BINARY_NAME) . + +# Run unit tests +test: + go test -v ./... + +# Run tests with coverage +test-cover: + go test -v -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +# Run integration tests against a mock server +# Assumes mock server is running on localhost:$(MOCK_PORT) +test-mock: build + ./$(BINARY_NAME) --base-url=http://localhost:$(MOCK_PORT) --insecure + +# Remove build artifacts +clean: + rm -f $(BINARY_NAME) + rm -f coverage.out coverage.html + rm -rf dist/ + +# Run golangci-lint +lint: + golangci-lint run + +# Format code +fmt: + go fmt ./... + +# Run go vet +vet: + go vet ./... + +# Tidy and verify dependencies +tidy: + go mod tidy + go mod verify + +# Quick check: fmt, vet, build +check: fmt vet build + +# Full validation: tidy, fmt, vet, lint, test, build +all: tidy fmt vet lint test build + +.DEFAULT_GOAL := build +`, + }, + { + Path: "CLAUDE.md", + Template: `# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with this connector. + +## Project Overview + +This is a ConductorOne Baton connector for {{.NameTitle}}. It syncs identity and access data from {{.NameTitle}} into ConductorOne. + +## Build and Run + +` + "```" + `bash +# Build +go build -o baton-{{.Name}} . + +# Run sync +./baton-{{.Name}} + +# Run with verbose logging +./baton-{{.Name}} --log-level debug +` + "```" + ` + +## Standard Connector Structure + +` + "```" + ` +baton-{{.Name}}/ + main.go # Entry point: config, connector init + pkg/connector/ + connector.go # Metadata, ResourceSyncers(), Validate() + resource_types.go # Resource type definitions + users.go # User resource syncer + groups.go # Group resource syncer (add as needed) + pkg/client/ # Optional: API wrapper + CLAUDE.md # This file +` + "```" + ` + +## Key Patterns + +### 1. ResourceSyncer Interface + +Every resource type implements: +- ` + "`" + `List()` + "`" + ` - Return all resources (with pagination) +- ` + "`" + `Entitlements()` + "`" + ` - Return available permissions for a resource +- ` + "`" + `Grants()` + "`" + ` - Return who has what permissions + +### 2. Error Wrapping + +Always prefix errors with connector name: +` + "```" + `go +return nil, fmt.Errorf("baton-{{.Name}}: failed to list users: %w", err) +` + "```" + ` + +### 3. Pagination + +Always paginate API calls: +` + "```" + `go +func (u *userBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + // Get page from token + page, _ := parsePageToken(pToken.Token) + + // Fetch one page + users, nextCursor, err := u.client.ListUsers(ctx, page, pageSize) + + // Return next token + return resources, nextCursor, nil, nil +} +` + "```" + ` + +### 4. User Resource Creation + +` + "```" + `go +import rs "github.com/conductorone/baton-sdk/pkg/types/resource" + +resource, err := rs.NewUserResource( + user.DisplayName, + userResourceType, + user.ID, + []rs.UserTraitOption{ + rs.WithEmail(user.Email, true), + rs.WithUserLogin(user.Username), + rs.WithStatus(v2.UserTrait_Status_STATUS_ENABLED), + }, +) +` + "```" + ` + +## Testing Requirements + +### Configurable Base URL + +The connector MUST support ` + "`" + `--base-url` + "`" + ` flag for testing against mocks: +` + "```" + `go +field.StringField("base-url", + field.WithDescription("Base URL for API (for testing)"), + field.WithDefaultValue("https://api.example.com"), +), +` + "```" + ` + +### Insecure TLS Option + +Support ` + "`" + `--insecure` + "`" + ` for self-signed certs in testing: +` + "```" + `go +field.BoolField("insecure", + field.WithDescription("Skip TLS verification (testing only)"), +), +` + "```" + ` + +## Common Pitfalls + +1. **Don't swallow errors** - Always return errors, don't log and continue +2. **Don't buffer entire datasets** - Always paginate +3. **Don't ignore context** - Pass ctx to all API calls +4. **Don't log credentials** - Never log tokens or API keys +5. **Don't use empty display names** - Fall back to ID if name is empty + +## Work Tracking + +Track work in TODO.md: +- Create TODO.md for pending tasks +- Move completed items to COMPLETED section +- Add QUESTIONS section for clarifications needed + +## Reference + +For comprehensive patterns and best practices, see: +- baton-demo: https://github.com/conductorone/baton-demo +- baton-github: https://github.com/conductorone/baton-github +- baton-sdk docs: https://github.com/conductorone/baton-sdk +`, + }, + { + Path: "docs/README.md", + Template: `# Documentation + +Place documentation about the downstream {{.NameTitle}} API here. + +This helps Claude Code understand the API and build appropriate mocks for testing. + +## Suggested Content + +1. **API Authentication** - How to authenticate (API key, OAuth, etc.) +2. **Endpoints** - Key endpoints the connector needs +3. **Data Models** - User, group, role structures +4. **Rate Limits** - API rate limiting behavior +5. **Pagination** - How the API paginates results + +## Example + +` + "```" + ` +GET /api/v1/users +Authorization: Bearer {token} + +Response: +{ + "users": [...], + "next_cursor": "abc123" +} +` + "```" + ` +`, + }, + { + Path: "docs/.gitignore", + Template: `# Ignore everything in docs except README and API_NOTES +* +!.gitignore +!README.md +!API_NOTES.md +`, + }, + { + Path: "docs/API_NOTES.md", + Template: `# {{.NameTitle}} API Notes + +This document captures API behavior, quirks, and implementation notes discovered during connector development. + +## Authentication + +` + "```" + ` +# TODO: Document authentication method +# Example: +# Authorization: Bearer {api_key} +# X-API-Key: {api_key} +` + "```" + ` + +## Pagination + +` + "```" + ` +# TODO: Document pagination pattern +# Example cursor-based: +# GET /users?cursor=abc123&limit=100 +# Response: { "users": [...], "next_cursor": "def456" } +# +# Example offset-based: +# GET /users?page=2&per_page=100 +# Response: { "users": [...], "total": 1234 } +` + "```" + ` + +## Rate Limits + +` + "```" + ` +# TODO: Document rate limits +# Example: +# X-RateLimit-Limit: 1000 +# X-RateLimit-Remaining: 999 +# X-RateLimit-Reset: 1234567890 +` + "```" + ` + +## Key Endpoints + +### Users + +` + "```" + ` +# List users +GET /api/v1/users + +# Get single user +GET /api/v1/users/{id} + +# Response shape +{ + "id": "user-123", + "email": "user@example.com", + "name": "Display Name", + "status": "active" +} +` + "```" + ` + +### Groups (if applicable) + +` + "```" + ` +# TODO: Document group endpoints +` + "```" + ` + +### Roles/Permissions (if applicable) + +` + "```" + ` +# TODO: Document role/permission endpoints +` + "```" + ` + +## Quirks and Gotchas + +- TODO: Document any API quirks discovered during implementation +- Example: "User IDs are case-sensitive" +- Example: "Empty arrays are returned as null, not []" +- Example: "Deleted users still appear in list with status=deleted" + +## Mock Server Notes + +When building a mock server for testing, ensure it: +1. Returns proper pagination tokens +2. Handles the authentication header +3. Returns realistic response shapes + +See ` + "`" + `mocks/` + "`" + ` directory for mock server implementation. +`, + }, + { + Path: ".github/workflows/ci.yaml", + Template: `name: ci + +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches: + - main + +jobs: + go-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Run linters + uses: golangci/golangci-lint-action@v8 + with: + version: latest + args: --timeout=3m + + go-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Run tests + run: go test -v -covermode=count ./... + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Build connector + run: go build -o baton-{{.Name}} . + - name: Verify binary runs + run: ./baton-{{.Name}} --help +`, + }, + { + Path: ".github/workflows/release.yaml", + Template: `name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + # Uses ConductorOne's shared release workflow + # Documentation: https://github.com/ConductorOne/github-workflows + uses: ConductorOne/github-workflows/.github/workflows/release.yaml@v2 + with: + tag: ${{"{{"}} github.ref_name {{"}}"}} + lambda: false # Set to true if you need Lambda deployment + secrets: + RELENG_GITHUB_TOKEN: ${{"{{"}} secrets.RELENG_GITHUB_TOKEN {{"}}"}} + APPLE_SIGNING_KEY_P12: ${{"{{"}} secrets.APPLE_SIGNING_KEY_P12 {{"}}"}} + APPLE_SIGNING_KEY_P12_PASSWORD: ${{"{{"}} secrets.APPLE_SIGNING_KEY_P12_PASSWORD {{"}}"}} + AC_PASSWORD: ${{"{{"}} secrets.AC_PASSWORD {{"}}"}} + AC_PROVIDER: ${{"{{"}} secrets.AC_PROVIDER {{"}}"}} + DATADOG_API_KEY: ${{"{{"}} secrets.DATADOG_API_KEY {{"}}"}} +`, + }, + { + Path: ".golangci.yml", + Template: `version: "2" +linters: + default: none + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - durationcheck + - errcheck + - errorlint + - exhaustive + - goconst + - gocritic + - godot + - gosec + - govet + - ineffassign + - nakedret + - nilerr + - noctx + - revive + - staticcheck + - unconvert + - unused + - whitespace + settings: + exhaustive: + default-signifies-exhaustive: true + govet: + enable-all: true + disable: + - fieldalignment + - shadow + nakedret: + max-func-lines: 0 +`, + }, + { + Path: "Dockerfile", + Template: `# Build stage +FROM golang:1.23-alpine AS builder +WORKDIR /app + +# Copy go mod files first for better caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build static binary +RUN CGO_ENABLED=0 GOOS=linux go build -o /build/baton-{{.Name}} . + +# Runtime stage - distroless for security +# - No shell, no package manager (minimal attack surface) +# - Runs as nonroot user (uid 65534) +# - Only includes: CA certificates, timezone data +FROM gcr.io/distroless/static-debian11:nonroot + +# Copy binary from build stage +COPY --from=builder /build/baton-{{.Name}} / + +# Run as nonroot user (distroless default) +USER 65534 + +# Set entrypoint +ENTRYPOINT ["/baton-{{.Name}}"] + +# OCI metadata labels +LABEL org.opencontainers.image.title="baton-{{.Name}}" +LABEL org.opencontainers.image.description="{{.Description}}" +LABEL org.opencontainers.image.source="{{.ModulePath}}" +`, + }, + { + Path: "Dockerfile.lambda", + Template: `# Lambda deployment variant +# Use this for AWS Lambda deployments + +# Build stage +FROM golang:1.23-alpine AS builder +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build with Lambda support tag +RUN CGO_ENABLED=0 GOOS=linux go build -tags baton_lambda_support -o /build/baton-{{.Name}} . + +# Runtime stage - AWS Lambda provided runtime +FROM public.ecr.aws/lambda/provided:al2023 + +# Copy binary +COPY --from=builder /build/baton-{{.Name}} /var/task/ + +# Lambda entrypoint (note: "lambda" argument triggers Lambda mode) +ENTRYPOINT ["/var/task/baton-{{.Name}}", "lambda"] +`, + }, + { + Path: "docker-compose.yml", + Template: `# Docker Compose for local development and testing +# +# Usage: +# docker compose up # Run connector in daemon mode +# docker compose run baton # Run one-shot sync +# +version: '3.9' + +services: + baton: + build: . + environment: + # ConductorOne daemon mode credentials + # Required for long-running daemon mode + - BATON_CLIENT_ID=${BATON_CLIENT_ID:-} + - BATON_CLIENT_SECRET=${BATON_CLIENT_SECRET:-} + # Connector-specific credentials + # TODO: Add your API credentials here + # - BATON_API_KEY=${BATON_API_KEY:-} + # Uncomment for one-shot mode (sync to file): + # volumes: + # - ./output:/work + # command: ["-f", "/work/sync.c1z"] + + # Optional: Mock server for testing + # mock: + # build: + # context: ./mocks + # ports: + # - "8089:8089" +`, + }, + { + Path: "pkg/client/client.go", + Template: `// Package client provides an HTTP client for the {{.NameTitle}} API. +// +// This package wraps the {{.NameTitle}} REST API with Go types and handles: +// - Authentication +// - Pagination +// - Error handling +// - Rate limiting (optional) +package client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// Client wraps the {{.NameTitle}} API. +type Client struct { + baseURL string + httpClient *http.Client + // TODO: Add authentication fields + // Example: apiKey string +} + +// New creates a new {{.NameTitle}} API client. +// +// Parameters: +// - baseURL: API base URL (e.g., "https://api.example.com") +// - httpClient: HTTP client (can be configured for insecure TLS in tests) +func New(baseURL string, httpClient *http.Client) (*Client, error) { + if baseURL == "" { + return nil, fmt.Errorf("baseURL is required") + } + if httpClient == nil { + httpClient = http.DefaultClient + } + + // Parse and validate base URL + u, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid baseURL: %w", err) + } + + return &Client{ + baseURL: u.String(), + httpClient: httpClient, + }, nil +} + +// User represents a user from the {{.NameTitle}} API. +// TODO: Update fields to match actual API response. +type User struct { + ID string ` + "`" + `json:"id"` + "`" + ` + Email string ` + "`" + `json:"email"` + "`" + ` + Name string ` + "`" + `json:"name"` + "`" + ` + Username string ` + "`" + `json:"username,omitempty"` + "`" + ` + Status string ` + "`" + `json:"status"` + "`" + ` +} + +// ListUsersResponse is the API response for listing users. +// TODO: Update to match actual API response shape. +type ListUsersResponse struct { + Users []User ` + "`" + `json:"users"` + "`" + ` + NextCursor string ` + "`" + `json:"next_cursor,omitempty"` + "`" + ` +} + +// ListUsers returns a page of users from the API. +// +// Parameters: +// - cursor: Pagination cursor (empty for first page) +// - limit: Maximum users to return per page +// +// Returns: +// - users: List of users +// - nextCursor: Cursor for next page (empty if no more pages) +func (c *Client) ListUsers(ctx context.Context, cursor string, limit int) ([]User, string, error) { + // Build request URL + reqURL := fmt.Sprintf("%s/api/v1/users?limit=%d", c.baseURL, limit) + if cursor != "" { + reqURL += "&cursor=" + url.QueryEscape(cursor) + } + + // Create request + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, "", fmt.Errorf("failed to create request: %w", err) + } + + // TODO: Add authentication + // req.Header.Set("Authorization", "Bearer "+c.apiKey) + + // Execute request + resp, err := c.httpClient.Do(req) + if err != nil { + // HTTP errors (timeouts, connection refused) may have nil response + return nil, "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // Check status + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + // Parse response + var result ListUsersResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, "", fmt.Errorf("failed to decode response: %w", err) + } + + // PAGINATION: Return API's next token for termination, not len(results) < limit + // Some APIs return empty pages before the final page, so result count is unreliable + return result.Users, result.NextCursor, nil +} + +// GetUser returns a single user by ID. +// +// TYPE ASSERTION SAFETY: When working with interface{} or map[string]any: +// WRONG: userID := data["user_id"].(string) // Panics if missing or wrong type +// CORRECT: +// userID, ok := data["user_id"].(string) +// if !ok { return nil, fmt.Errorf("user_id missing or not string") } +func (c *Client) GetUser(ctx context.Context, userID string) (*User, error) { + reqURL := fmt.Sprintf("%s/api/v1/users/%s", c.baseURL, url.PathEscape(userID)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // TODO: Add authentication + // req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("user not found: %s", userID) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var user User + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &user, nil +} +`, + }, +} diff --git a/pkg/scaffold/scaffold_test.go b/pkg/scaffold/scaffold_test.go new file mode 100644 index 00000000..59267459 --- /dev/null +++ b/pkg/scaffold/scaffold_test.go @@ -0,0 +1,236 @@ +package scaffold + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestGenerate(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + + outputDir := filepath.Join(tmpDir, "baton-test-app") + + cfg := &Config{ + Name: "test-app", + ModulePath: "github.com/example/baton-test-app", + OutputDir: outputDir, + Description: "Test connector for testing", + } + + if err := Generate(cfg); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Verify expected files exist + // These match the files generated by scaffold.Generate + // Note: connectors sync three fundamental resource types: + // - users (principals that receive grants) + // - groups (with "member" entitlement) + // - roles (with "assigned" entitlement) + expectedFiles := []string{ + "go.mod", + "main.go", + "pkg/connector/connector.go", + "pkg/connector/resource_types.go", + "pkg/connector/users.go", + "pkg/connector/groups.go", + "pkg/connector/roles.go", + "pkg/connector/actions.go", + "pkg/client/client.go", + "docs/README.md", + "docs/API_NOTES.md", + "docs/.gitignore", + ".gitignore", + ".golangci.yml", + ".github/workflows/ci.yaml", + ".github/workflows/release.yaml", + "README.md", + "Makefile", + "CLAUDE.md", + "Dockerfile", + "Dockerfile.lambda", + "docker-compose.yml", + } + + for _, f := range expectedFiles { + path := filepath.Join(outputDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected file %s to exist", f) + } + } + + // Verify go.mod contains module path + goMod, err := os.ReadFile(filepath.Join(outputDir, "go.mod")) + if err != nil { + t.Fatalf("failed to read go.mod: %v", err) + } + if !strings.Contains(string(goMod), "github.com/example/baton-test-app") { + t.Error("go.mod should contain module path") + } + + // Verify main.go contains connector name + mainGo, err := os.ReadFile(filepath.Join(outputDir, "main.go")) + if err != nil { + t.Fatalf("failed to read main.go: %v", err) + } + if !strings.Contains(string(mainGo), "baton-test-app") { + t.Error("main.go should contain connector name") + } +} + +func TestGenerateDefaults(t *testing.T) { + tmpDir := t.TempDir() + + // Change to temp dir so default output dir works + t.Chdir(tmpDir) + + cfg := &Config{ + Name: "my-service", + } + + if err := Generate(cfg); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Verify defaults were applied + expectedDir := filepath.Join(tmpDir, "baton-my-service") + if _, err := os.Stat(expectedDir); os.IsNotExist(err) { + t.Error("expected default output directory baton-my-service") + } + + // Verify default module path + goMod, err := os.ReadFile(filepath.Join(expectedDir, "go.mod")) + if err != nil { + t.Fatalf("failed to read go.mod: %v", err) + } + if !strings.Contains(string(goMod), "github.com/conductorone/baton-my-service") { + t.Error("go.mod should contain default module path") + } +} + +func TestGenerateMissingName(t *testing.T) { + cfg := &Config{} + + err := Generate(cfg) + if err == nil { + t.Error("expected error for missing name") + } +} + +func TestToPascalCase(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"my-app", "MyApp"}, + {"test", "Test"}, + {"foo-bar-baz", "FooBarBaz"}, + {"", ""}, + } + + for _, tc := range tests { + result := toPascalCase(tc.input) + if result != tc.expected { + t.Errorf("toPascalCase(%q) = %q, expected %q", tc.input, result, tc.expected) + } + } +} + +// TestGenerateCompiles verifies that generated code actually compiles. +// This is a CRITICAL test - without it, templates can drift from working code +// and remain broken silently. This catches SDK API changes, import errors, etc. +// +// This test requires network access for go mod download. +// Set SKIP_COMPILE_TEST=1 to skip in offline CI environments. +func TestGenerateCompiles(t *testing.T) { + if testing.Short() { + t.Skip("skipping compilation test in short mode") + } + + if os.Getenv("SKIP_COMPILE_TEST") != "" { + t.Skip("skipping compilation test: SKIP_COMPILE_TEST is set") + } + + tmpDir := t.TempDir() + + outputDir := filepath.Join(tmpDir, "baton-compile-test") + + cfg := &Config{ + Name: "compile-test", + ModulePath: "github.com/example/baton-compile-test", + OutputDir: outputDir, + Description: "Test connector to verify compilation", + } + + if err := Generate(cfg); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Run go mod tidy to download dependencies + runCommand(t, outputDir, 5*time.Minute, "go", "mod", "tidy") + + // Run go build to verify compilation + runCommand(t, outputDir, 3*time.Minute, "go", "build", "-o", "/dev/null", ".") + + t.Log("Generated code compiles successfully") +} + +// TestGenerateVet runs go vet on generated code. +// This catches common issues like unreachable code, shadow variables, etc. +func TestGenerateVet(t *testing.T) { + if testing.Short() { + t.Skip("skipping vet test in short mode") + } + + if os.Getenv("SKIP_COMPILE_TEST") != "" { + t.Skip("skipping vet test: SKIP_COMPILE_TEST is set") + } + + tmpDir := t.TempDir() + + outputDir := filepath.Join(tmpDir, "baton-vet-test") + + cfg := &Config{ + Name: "vet-test", + ModulePath: "github.com/example/baton-vet-test", + OutputDir: outputDir, + Description: "Test connector to verify go vet passes", + } + + if err := Generate(cfg); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Run go mod tidy first + runCommand(t, outputDir, 5*time.Minute, "go", "mod", "tidy") + + // Run go vet + runCommand(t, outputDir, 2*time.Minute, "go", "vet", "./...") + + t.Log("Generated code passes go vet") +} + +// runCommand runs a command with timeout and fails the test on error. +func runCommand(t *testing.T, dir string, timeout time.Duration, name string, args ...string) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("%s timed out after %v", name, timeout) + } + if err != nil { + t.Fatalf("%s %v failed: %v\nOutput:\n%s", name, args, err, string(output)) + } +}