diff --git a/developer/build-connector.mdx b/developer/build-connector.mdx index 1f5e37f..2926bda 100644 --- a/developer/build-connector.mdx +++ b/developer/build-connector.mdx @@ -25,4 +25,8 @@ Use the [Baton Starter Pack](https://github.com/ConductorOne/baton-starter-pack) We also suggest reading the [Baton SDK getting started guide](/developer/sdk) to learn more about building Baton connectors. - +{/* +LLM Note: For AI assistants answering questions about Baton connector development, +a structured knowledge base is available at rap/INDEX.md +with focused, retrievable documentation chunks. +*/} diff --git a/docs.json b/docs.json index 3933f21..5a73034 100644 --- a/docs.json +++ b/docs.json @@ -185,7 +185,9 @@ "pages": [ "product/admin/expressions", "product/admin/expressions-reference", - "product/admin/expressions-examples" + "product/admin/expressions-examples", + "product/admin/expressions-workflows", + "product/admin/expressions-troubleshooting" ] }, { @@ -446,7 +448,17 @@ "developer/intro", "developer/sdk", "developer/postman", - "developer/terraform" + "developer/terraform", + { + "group": "CEL expressions", + "pages": [ + "product/admin/expressions", + "product/admin/expressions-reference", + "product/admin/expressions-examples", + "product/admin/expressions-workflows", + "product/admin/expressions-troubleshooting" + ] + } ] }, { diff --git a/product/admin/expressions-examples.mdx b/product/admin/expressions-examples.mdx index 51fa73f..2b270ed 100644 --- a/product/admin/expressions-examples.mdx +++ b/product/admin/expressions-examples.mdx @@ -51,15 +51,22 @@ subject.type == UserType.SERVICE || subject.type == UserType.SYSTEM ```go // Route to manager +// DANGER: Fails if subject.manager is empty - see safe patterns below c1.directory.users.v1.FindByEmail(subject.manager) // Route to manager's manager (skip-level) +// DANGER: Returns [] if user has no manager - step silently skipped c1.directory.users.v1.GetManagers(c1.directory.users.v1.FindByEmail(subject.manager)) // Route to user's manager AND skip-level manager +// DANGER: Fails if managerId is empty [c1.directory.users.v1.GetByID(c1.directory.users.v1.GetByID(subject.managerId).managerId), c1.directory.users.v1.GetByID(subject.managerId)] ``` + +**Manager lookups can return empty results.** When `GetManagers` returns `[]` in an approver expression, the approval step is silently skipped. Always add fallback approvers - see [Approver selection patterns](#approver-selection-patterns) below. + + ### Access conflict detection ```go @@ -100,12 +107,14 @@ task.origin == TaskOrigin.API ```go // Check custom attribute +// SILENT FALSE: Returns false if attribute doesn't exist - use has() to check first subject.attributes.contractor == "true" // Check if custom attribute exists has(subject.attributes.securityClearance) // Conditional logic based on custom attribute +// SAFE: Uses has() to check existence before accessing has(subject.attributes.director) ? subject.attributes.director == "boss@company.com" : subject.manager == "manager@company.com" ``` @@ -177,24 +186,30 @@ c1.user.v1.AutomaticallyGrantedFromEnrollment(subject, entitlement.appId, entitl **Simple user returns:** ```go -subject // Self-approval -c1.directory.users.v1.FindByEmail(subject.manager) // Route to manager +subject // Self-approval - always valid +c1.directory.users.v1.FindByEmail(subject.manager) // DANGER: Fails if manager field is empty ``` **Conditional user routing:** ```go +// DANGER: FindByEmail branch fails if subject.manager is empty subject.department == "IT" ? subject : c1.directory.users.v1.FindByEmail(subject.manager) ``` **Complex nested routing:** ```go -has(subject.profile.propThatOnlyExistsSometimes) ? - subject.profile.propThatOnlyExistsSometimes == "Value That Happens" ? - c1.directory.users.v1.FindByEmail("some.email@company.com") : - c1.directory.users.v1.GetByID("user123") : +// SAFE: Uses has() for optional field, hardcoded IDs as fallbacks +has(subject.profile.propThatOnlyExistsSometimes) ? + subject.profile.propThatOnlyExistsSometimes == "Value That Happens" ? + c1.directory.users.v1.FindByEmail("some.email@company.com") : + c1.directory.users.v1.GetByID("user123") : c1.directory.users.v1.GetByID("fallback123") ``` + +For production approver expressions, prefer the patterns in [Approver selection patterns](#approver-selection-patterns) which include proper fallbacks. + + ## Real-world policy examples These examples are based on actual customer requests and common use cases. Use them as starting points for your own policies, adapting the logic to match your organization's needs. @@ -491,9 +506,86 @@ has(subject.profile.hire_date) && time.try_parse(subject.profile.hire_date, TimeFormat.DATE, now()) <= now() ``` +## Approver selection patterns + +These patterns are for **policy step approvers** - expressions that return one or more users. The critical rule: always include fallback approvers to prevent steps from being silently skipped. + +### Why fallbacks matter + +When an approver expression returns an empty list `[]`, the approval step is **silently skipped** rather than failing. This can inadvertently auto-approve requests that should have been reviewed. + +```go +// DANGER: Returns [] if user has no manager - step skipped entirely +c1.directory.users.v1.GetManagers(subject) + +// SAFE: Falls back to app owners if no manager +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? c1.directory.users.v1.GetManagers(subject) + : appOwners +``` + +### Manager with fallback + +```go +// Route to manager, fall back to app owners +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? c1.directory.users.v1.GetManagers(subject) + : appOwners + +// Route to first manager only (not all managers) +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? [c1.directory.users.v1.GetManagers(subject)[0]] + : appOwners +``` + +### Skip-level manager with fallback + +```go +// Get manager's manager, fall back to direct manager, then app owners +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? (size(c1.directory.users.v1.GetManagers(c1.directory.users.v1.GetManagers(subject)[0])) > 0 + ? c1.directory.users.v1.GetManagers(c1.directory.users.v1.GetManagers(subject)[0]) + : c1.directory.users.v1.GetManagers(subject)) + : appOwners +``` + +### Entitlement members with fallback + +```go +// Route to users who have this entitlement, fall back to app owners +size(c1.directory.apps.v1.GetEntitlementMembers(entitlement.appId, entitlement.id)) > 0 + ? c1.directory.apps.v1.GetEntitlementMembers(entitlement.appId, entitlement.id) + : appOwners +``` + +### Conditional approver by department + +```go +// Engineering: route to tech lead group +// Others: route to manager with fallback +subject.department == "Engineering" + ? c1.directory.apps.v1.GetEntitlementMembers("groups-app", "tech-leads") + : (size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? c1.directory.users.v1.GetManagers(subject) + : appOwners) +``` + +### Multiple approver groups + +```go +// Combine app owners and security team (for high-risk access) +appOwners + c1.directory.apps.v1.GetEntitlementMembers("groups-app", "security-team") +``` + + +The `appOwners` variable is always available in policy step expressions and never returns an empty list - making it the safest fallback option. + + ## Related documentation - **[Write condition expressions](/product/admin/expressions)** - Introduction to CEL expressions and where they're used - **[CEL expressions reference](/product/admin/expressions-reference)** - Complete reference for all available objects, functions, and time functions +- **[Workflow expressions](/product/admin/expressions-workflows)** - Pass data between automation steps using the ctx object +- **[Troubleshooting expressions](/product/admin/expressions-troubleshooting)** - Debug common errors and understand failure modes diff --git a/product/admin/expressions-reference.mdx b/product/admin/expressions-reference.mdx index 1eb6c8a..2d7a440 100644 --- a/product/admin/expressions-reference.mdx +++ b/product/admin/expressions-reference.mdx @@ -6,6 +6,227 @@ description: Complete reference for CEL expression objects, functions, and usage --- {/* Editor Refresh: 2026-01-16 */} +## Type definitions + +CEL expressions use typed values. Understanding these types helps you write correct expressions and avoid runtime errors. + +### Primitive types + +| Type | Description | Example values | +|:-----|:------------|:---------------| +| `string` | Text value | `"Engineering"`, `"user@company.com"` | +| `bool` | Boolean true/false | `true`, `false` | +| `int` | Integer number | `0`, `42`, `-1` | +| `double` | Floating-point number | `3.14`, `0.5` | +| `bytes` | Binary data | Rarely used directly | + +### Time types + +| Type | Description | How to create | +|:-----|:------------|:--------------| +| `timestamp` | A point in time (UTC) | `now()`, `timestamp("2025-01-01T00:00:00Z")`, `time.parse(...)` | +| `duration` | A length of time | `duration("24h")`, `duration("30m")`, `duration("720h")` | + +**Duration format:** Use `h` for hours, `m` for minutes, `s` for seconds. Examples: `"2h"` (2 hours), `"30m"` (30 minutes), `"720h"` (30 days). + +**Timestamp arithmetic:** +```go +now() + duration("24h") // 24 hours from now +now() - duration("720h") // 30 days ago +timestamp1 - timestamp2 // Returns a duration +``` + +### Collection types + +| Type | Description | Example | +|:-----|:------------|:--------| +| `list` | Ordered list of items | `[user1, user2]`, `["a", "b", "c"]` | +| `map` | Key-value mapping | `subject.profile` (map of string to any) | + +**List operations:** +```go +size(myList) // Number of items +myList[0] // First item (0-indexed) +"value" in myList // Check membership +myList + otherList // Concatenate lists +myList.filter(x, x.status == UserStatus.ENABLED) // Filter +myList.map(x, x.email) // Transform +myList.exists(x, x.department == "IT") // Any match? +myList.all(x, x.status == UserStatus.ENABLED) // All match? +``` + +### Enum types + +Enums are predefined constants. Always use the full enum name (e.g., `UserStatus.ENABLED`, not just `ENABLED`). + +#### UserStatus + +| Value | Meaning | +|:------|:--------| +| `UserStatus.ENABLED` | User is active | +| `UserStatus.DISABLED` | User is disabled | +| `UserStatus.DELETED` | User is deleted | + +#### UserType + +| Value | Meaning | +|:------|:--------| +| `UserType.HUMAN` | Regular human user | +| `UserType.AGENT` | Automated agent | +| `UserType.SERVICE` | Service account | +| `UserType.SYSTEM` | System account | + +#### TaskOrigin + +| Value | Meaning | +|:------|:--------| +| `TaskOrigin.WEBAPP` | Created in ConductorOne web interface | +| `TaskOrigin.SLACK` | Created via Slack integration | +| `TaskOrigin.API` | Created via API | +| `TaskOrigin.JIRA` | Created via Jira integration | +| `TaskOrigin.COPILOT` | Created via Copilot | +| `TaskOrigin.PROFILE_MEMBERSHIP_AUTOMATION` | Created by automation | +| `TaskOrigin.TIME_REVOKE` | Created by time-based revocation | + +#### AppUserStatus (for triggers) + +Used in `ctx.trigger.oldAccount.status.status` and `ctx.trigger.newAccount.status.status`: + +| Value | Meaning | +|:------|:--------| +| `APP_USER_STATUS_ENABLED` | Account is active | +| `APP_USER_STATUS_DISABLED` | Account is disabled | +| `APP_USER_STATUS_DELETED` | Account is deleted | + +#### TimeFormat + +| Constant | Format | Example output | +|:---------|:-------|:---------------| +| `TimeFormat.RFC3339` | ISO 8601 / RFC 3339 | `2025-10-22T14:30:00Z` | +| `TimeFormat.DATE` | Date only | `2025-10-22` | +| `TimeFormat.DATETIME` | Date and time | `2025-10-22 14:30:00` | +| `TimeFormat.TIME` | Time only | `14:30:00` | + +### Object types + +These are complex types returned by functions or available as variables. + + +**User vs AppUser:** These are different types. A `User` is a person in the ConductorOne directory (your identity provider sync). An `AppUser` is that person's account within a specific application (their GitHub account, Okta account, etc.). One User can have many AppUsers across different apps. + + +#### User + +Represents a person in the ConductorOne directory. This is your organization's user record, typically synced from an identity provider. + +**Returned by:** `FindByEmail`, `GetByID`, `GetManagers`, `DirectReports`, `GetEntitlementMembers` + +**Available as:** `subject`, elements of `appOwners`, `ctx.trigger.oldUser`, `ctx.trigger.newUser` + +| Field | Type | Description | +|:------|:-----|:------------| +| `id` | string | Unique user identifier | +| `email` | string | Primary email address | +| `emails` | list<string> | All email addresses | +| `displayName` | string | Display name | +| `username` | string | Username | +| `usernames` | list<string> | All usernames | +| `department` | string | Department | +| `jobTitle` | string | Job title | +| `employmentType` | string | Employment type (e.g., "Full Time") | +| `employmentStatus` | string | Employment status (e.g., "Active") | +| `status` | UserStatus | User status enum | +| `directoryStatus` | UserStatus | Directory sync status | +| `type` | UserType | User type enum | +| `manager` | string | Manager's email | +| `manager_id` | string | Manager's user ID | +| `profile` | map | Custom profile attributes | +| `attributes` | map | Custom user attributes | + +#### Group + +Represents a ConductorOne group. Returned by `FindByName`. + +| Field | Type | Description | +|:------|:-----|:------------| +| `id` | string | Group identifier | +| `app_id` | string | App ID the group belongs to | +| `display_name` | string | Group display name | + +#### AppUser + +Represents a user's account within a specific connected application (e.g., their GitHub account, Salesforce account, AWS IAM user). Different from `User` which is the directory-level identity. + +**Returned by:** `ListAppUsersForUser` + +**Available as:** `ctx.trigger.oldAccount`, `ctx.trigger.newAccount` (in account-change triggers) + +| Field | Type | Description | +|:------|:-----|:------------| +| `id` | string | App user identifier | +| `displayName` | string | Display name | +| `username` | string | Username in the app | +| `usernames` | list<string> | All usernames | +| `email` | string | Email in the app | +| `emails` | list<string> | All emails | +| `employeeIds` | list<string> | Employee IDs | +| `status` | AppUserStatus | Nested status object | +| `status.status` | enum | APP_USER_STATUS_ENABLED/DISABLED/DELETED | +| `status.details` | string | Status details | +| `profile` | map | Custom profile attributes | +| `attributes` | map | Attribute mappings | + +#### Task + +Represents an access request or task in ConductorOne. See [Task object](#task-object) for all fields. + +**Available as:** `task` (in policy expressions) + +#### TaskAnalysis + +Analysis data attached to a task, including access conflict information. See [Task analysis object](#task-analysis-object) for all fields. + +**Available as:** `task.analysis` (in policy expressions) + +#### Entitlement + +The entitlement (permission/role) being requested. See [Entitlement object](#entitlement-object) for all fields. + +**Available as:** `entitlement` (in policy expressions) + +#### IP + +Represents an IP address with properties for classification. See [IP address object](#ip-address-object) for all fields. + +**Created by:** `ip("1.2.3.4")` + +#### CIDR + +Represents a network range for IP matching. See [IP CIDR object](#ip-cidr-object) for all methods. + +**Created by:** `cidr("10.0.0.0/8")` + +#### Context (ctx) + +Workflow execution context containing trigger data and step outputs. See [Context object](#context-object-ctx) for all fields. + +**Available as:** `ctx` (in automation triggers and workflow steps) + +### Built-in variables + +These variables are automatically available in specific contexts. + +| Variable | Type | Available in | Description | +|:---------|:-----|:-------------|:------------| +| `subject` | User | Policies, Groups, Automations, Campaigns, Account provisioning | The current user being evaluated | +| `task` | Task | Policies only | The current access request | +| `entitlement` | Entitlement | Policies only | The entitlement being requested | +| `appOwners` | list<User> | Policy step approvers only | Owners of the application | +| `ctx` | Context | Automations only | Workflow context and trigger data | +| `ip` | IP | Policies, Automations | Requestor's IP address (when available) | + +--- + ## Functions These library functions let you interact with the ConductorOne system to look up whether a user has access to a certain application or entitlement, or to find the user or list of users who should review a task. @@ -20,6 +241,10 @@ These library functions let you interact with the ConductorOne system to look up | `c1.user.v1.AutomaticallyGrantedFromEnrollment` | user, app ID, entitlement ID | Boolean | Policy conditions only | | `c1.user.v1.ListAppUsersForUser` | user, app ID | List of AppUser | Automations, Account provisioning | +**What can go wrong:** +- Invalid app ID or entitlement ID returns `false` for boolean functions (no error thrown) +- `ListAppUsersForUser` returns empty list `[]` if user has no accounts in the app + ### Directory library functions | Function | Accepts | Returns | Availability | @@ -31,8 +256,29 @@ These library functions let you interact with the ConductorOne system to look up | `c1.directory.groups.v1.FindByName` | group name | group | Policies, Groups, Automations, Account provisioning | | `c1.directory.apps.v1.GetEntitlementMembers` | app ID, entitlement ID | list of users | Policies, Groups, Automations, Account provisioning | +**What can go wrong:** +- `FindByEmail` fails if email doesn't exist in directory - verify emails before deploying +- `GetByID` fails if user ID doesn't exist +- `GetManagers` returns empty list `[]` if user has no manager - **this silently skips approval steps** +- `DirectReports` returns empty list `[]` if user has no reports +- `GetEntitlementMembers` returns empty list `[]` if entitlement has no members + + +**Critical:** When `GetManagers` returns an empty list in a policy approver expression, the approval step is silently skipped rather than failing. Always add a fallback approver: + +```go +// DANGER without fallback: step skipped if no manager +c1.directory.users.v1.GetManagers(subject) + +// SAFE with fallback: uses app owners if no manager +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? c1.directory.users.v1.GetManagers(subject) + : appOwners +``` + + -Go to an application or entitlement's details page to look up its ID, or use [Cone](/product/cli/install/). +Go to an application or entitlement's details page to look up its ID, or use [Cone](/product/cli/install/). @@ -69,14 +315,7 @@ ConductorOne provides comprehensive time functions for working with dates and ti #### TimeFormat constants -| Constant | Format | Example Output | -| :--- | :--- | :--- | -| `TimeFormat.RFC3339` | ISO 8601 / RFC 3339 | `2025-10-22T14:30:00Z` | -| `TimeFormat.DATE` | Date only | `2025-10-22` | -| `TimeFormat.DATETIME` | Date and time | `2025-10-22 14:30:00` | -| `TimeFormat.TIME` | Time only | `14:30:00` | - -You can also use custom Go time layouts like `"2006-01-02"` or `"Monday, January 2, 2006"`. +See [TimeFormat enum](#timeformat) for all constants. You can also use custom Go time layouts like `"2006-01-02"` or `"Monday, January 2, 2006"`. #### Common time patterns @@ -163,6 +402,11 @@ These fields are used in the majority of condition expressions: | `subject.email.startsWith` | string | Email prefix check | `subject.email.startsWith("admin")` | Admin user identification | | `subject.email.endsWith` | string | Email domain check | `subject.email.endsWith("@company.com")` | Employee vs contractor | | `subject.manager` | string | User's manager email | `subject.manager == "boss@company.com"` | Manager-based routing | +| `subject.manager_id` | string | User's manager ID | `subject.manager_id != ""` | Check if user has manager | + + +Both `subject.manager` (email) and `subject.manager_id` (ID) are available. Use `manager` for email-based comparisons and `manager_id` to check existence or for ID-based lookups with `GetByID`. + #### Organizational fields @@ -180,19 +424,6 @@ These fields are used in the majority of condition expressions: | `subject.profile` | map[string]interface{} | Profile attributes | `subject.profile.department == "IT"` | Custom profile data access | | `subject.attributes.` | varies | Custom user attributes | `subject.attributes.contractor == "true"` | Custom business logic | -#### Enum values reference - -**UserStatus values:** -- `UserStatus.ENABLED` - User is active -- `UserStatus.DISABLED` - User is disabled -- `UserStatus.DELETED` - User is deleted - -**UserType values:** -- `UserType.HUMAN` - Regular human user -- `UserType.AGENT` - Automated agent -- `UserType.SERVICE` - Service account -- `UserType.SYSTEM` - System account - **Use custom user attributes.** @@ -233,14 +464,7 @@ The task object is used in policy expressions to reference the current access re | `task.requestorUserId` | string | ID of the user who created the task | `task.requestorUserId == "user123"` | Requestor-based routing | | `task.analysis` | analysis | Task analysis data | `task.analysis.hasConflictViolations` | Access conflict detection | -#### TaskOrigin values: -- `TaskOrigin.WEBAPP` - Created in ConductorOne web interface -- `TaskOrigin.SLACK` - Created via Slack integration -- `TaskOrigin.API` - Created via API -- `TaskOrigin.JIRA` - Created via Jira integration -- `TaskOrigin.COPILOT` - Created via Copilot -- `TaskOrigin.PROFILE_MEMBERSHIP_AUTOMATION` - Created by automation -- `TaskOrigin.TIME_REVOKE` - Created by time-based revocation +See [TaskOrigin enum](#taskorigin) for all possible values. ### Task analysis object diff --git a/product/admin/expressions-troubleshooting.mdx b/product/admin/expressions-troubleshooting.mdx new file mode 100644 index 0000000..3a428be --- /dev/null +++ b/product/admin/expressions-troubleshooting.mdx @@ -0,0 +1,342 @@ +--- +title: Troubleshoot CEL expressions +og:title: Troubleshoot CEL expressions - ConductorOne docs +og:description: Debug common errors, understand failure modes, and fix CEL expressions in ConductorOne. +description: Debug common errors, understand failure modes, and fix CEL expressions in ConductorOne. +--- + +When your CEL expression doesn't work, this guide helps you figure out why. Most problems fall into two categories: errors caught when you save (easy to fix) and silent failures at runtime (harder to debug). + +## Error types + +| Type | When caught | Example | +|:-----|:------------|:--------| +| **Compile-time** | When you save the expression | Syntax errors, type mismatches, undefined variables | +| **Runtime** | When the expression evaluates | Empty lists, missing users, function failures | + +Compile-time errors are caught immediately and prevent saving. Runtime errors are more subtle - your expression saves fine but behaves unexpectedly when it runs. + +--- + +## Common compile-time errors + +### Syntax errors + +**Error:** `Syntax error: mismatched input` + +```go +// BAD: Missing closing parenthesis +subject.department == "Engineering" + +// BAD: Wrong quote type - single quotes not valid in CEL +subject.department == 'Engineering' + +// GOOD: +subject.department == "Engineering" +``` + +--- + +### Undefined variable + +**Error:** `undeclared reference to 'xyz'` + +```go +// BAD: Typo in variable name +subjct.department == "Engineering" + +// BAD: Variable not available in this environment +ctx.trigger.user_id // ctx only available in workflows and automations + +// GOOD: +subject.department == "Engineering" +``` + + +Check the [expressions reference](/product/admin/expressions-reference) to see which variables are available in each context. + + +--- + +### Type mismatch + +**Error:** `found no matching overload` + +```go +// BAD: Comparing string to number +subject.profile["level"] > 5 // level might be stored as a string + +// GOOD: Convert to same type +int(subject.profile["level"]) > 5 +subject.profile["level"] == "5" +``` + +--- + +### Wrong return type + +**Error:** `expected type 'bool' but found 'User'` + +```go +// BAD: Policy condition must return true/false, not a User +c1.directory.users.v1.FindByEmail("alice@company.com") + +// GOOD: Return a boolean by adding a comparison +c1.directory.users.v1.FindByEmail("alice@company.com").department == "Engineering" +``` + +**Required return types by context:** + +| Context | Must return | +|:--------|:------------| +| Policy conditions | `true` or `false` | +| Dynamic groups | `true` or `false` | +| Policy step approvers | One or more users | +| Access review filters | `true` or `false` | +| Automation triggers | `true` or `false` | +| Account provisioning | Text value | + +--- + +## Common runtime issues + +These problems don't show errors when you save - they only appear when the expression runs against real data. + +### Empty list causes step skip + +**Symptom:** Policy step is skipped unexpectedly. + +**Cause:** Approver expression returned an empty list `[]`. + +```go +// DANGER: Returns [] if user has no manager -> step is SKIPPED, not failed +c1.directory.users.v1.GetManagers(subject) +``` + +**Solution:** Add a fallback approver: + +```go +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? c1.directory.users.v1.GetManagers(subject) + : appOwners +``` + + +This is the most common source of unexpected behavior in policy expressions. An empty approver list doesn't fail - it silently skips the step entirely. + + +--- + +### Index out of bounds + +**Symptom:** Expression fails with index error. + +**Cause:** Accessing `[0]` on an empty list. + +```go +// BAD: Fails if no managers exist +c1.directory.users.v1.GetManagers(subject)[0] + +// GOOD: Check size first +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? [c1.directory.users.v1.GetManagers(subject)[0]] + : appOwners +``` + +--- + +### User not found + +**Symptom:** `FindByEmail` or `GetByID` fails. + +**Cause:** The user doesn't exist in your directory, or the email/ID is wrong. + +```go +// DANGER: Fails if email doesn't exist +c1.directory.users.v1.FindByEmail("nonexistent@company.com") +``` + +**Solutions:** +1. Verify the user/email exists before deploying +2. Use entitlement-based approvers instead of hardcoded emails +3. For optional lookups, use conditional logic + +--- + +### Empty string comparisons + +**Symptom:** Expression returns `false` when you expect `true`. + +**Cause:** The field is empty, so it doesn't match your expected value. + +```go +// Returns false if department is empty (might be intentional, might not) +subject.department == "Engineering" + +// Explicit check for whether the field has a value +has(subject.department) && subject.department == "Engineering" +``` + + +`has()` checks if a field **exists**, not if it has a non-empty value. A field can exist with an empty string `""`, and `has()` will return `true`. + + +--- + +### Profile key missing + +**Symptom:** Expression fails when accessing profile data. + +**Cause:** The profile key doesn't exist for this user. + +```go +// BAD: Fails if costCenter doesn't exist in profile +subject.profile["costCenter"] == "CC-123" + +// GOOD: Check first using has() +has(subject.profile.costCenter) && subject.profile["costCenter"] == "CC-123" + +// ALTERNATIVE: Use "in" operator +"costCenter" in subject.profile && subject.profile["costCenter"] == "CC-123" +``` + +--- + +### Profile key has spaces + +**Symptom:** Syntax error or unexpected behavior. + +**Cause:** Dot notation doesn't work with spaces in key names. + +```go +// BAD: Dot notation fails with spaces +subject.profile.Cost Center + +// GOOD: Use bracket notation with quotes +"Cost Center" in subject.profile && subject.profile["Cost Center"] == "R&D" +``` + +--- + +## Debugging strategies + +### 1. Check return type first + +Before deploying, verify your expression returns the correct type: + +| If you're writing... | Expression must return... | +|:--------------------|:-------------------------| +| Policy condition | `true` or `false` | +| Dynamic group membership | `true` or `false` | +| Policy step approvers | One or more users | + +### 2. Preview dynamic groups + +Before saving a dynamic group expression: +1. Use the preview feature to see which users would be included +2. Check for both false positives (included but shouldn't be) and false negatives (excluded but shouldn't be) +3. Verify the group isn't empty or doesn't include everyone + +### 3. Test with specific users + +When debugging: +1. Pick a user who should match and one who shouldn't +2. Mentally evaluate the expression against both +3. Check intermediate values if using compound expressions + +### 4. Simplify and isolate + +For complex expressions, break them apart: + +```go +// Hard to debug +(a && b) || (c && !d && e) + +// Easier: Test each part separately +a // Does this work? +b // Does this work? +a && b // Now combine +``` + +--- + +## Error messages reference + +### Compile-time errors + +| Error message | Meaning | Fix | +|:--------------|:--------|:----| +| `Syntax error: mismatched input` | Brackets, quotes, or operators are wrong | Check syntax carefully | +| `undeclared reference to 'x'` | Variable doesn't exist or is misspelled | Check spelling, check context | +| `found no matching overload for 'func'` | Wrong argument types for function | Check function signature | +| `expected type 'X' but found 'Y'` | Return type doesn't match what's needed | Match the required return type | + +### Runtime behaviors + +| Behavior | Cause | Solution | +|:---------|:------|:---------| +| Step skipped | Approver expression returned `[]` | Add fallback approvers | +| Always false | Field is empty or missing | Use `has()` to check first | +| Index error | Accessing element in empty list | Check `size() > 0` first | +| Function error | User or resource not found | Verify IDs/emails before deploying | + +--- + +## Context-specific issues + +| Context | Common issue | Solution | +|:--------|:-------------|:---------| +| Automation triggers | Using wrong object type | Check if trigger is for users or accounts | +| Automation triggers | Nested nil objects | Check parent exists with `has()` | +| Access reviews | Using wrong scope | Know if you're in User scope or Account scope | +| Dynamic groups | Expression always true | Everyone gets included - narrow the filter | +| Workflows | Reference undefined step | Check step order and naming | +| Workflows | Template syntax error | Check matching `{{ }}` braces | +| Account provisioning | Optional variable not provided | Check if entitlement/task are available in context | + +--- + +## Best practices + +### Start simple + +```go +// Start with the simplest version +subject.department == "Engineering" + +// Add complexity only as needed +subject.department == "Engineering" && subject.jobTitle.contains("Manager") +``` + +### Always use fallbacks for approvers + +```go +// Always have a fallback so steps don't get skipped +size(managers) > 0 ? managers : appOwners +``` + +### Avoid hardcoded users + +```go +// BAD: Breaks when user leaves company +[c1.directory.users.v1.FindByEmail("john@company.com")] + +// GOOD: Uses a managed entitlement that can be updated +c1.directory.apps.v1.GetEntitlementMembers("approvers-app", "security-team") +``` + +### Document complex expressions + +If your expression is complex enough to need debugging, consider: +- Breaking it into multiple policy rules +- Adding comments (CEL supports `//` comments) +- Using multiple policy steps instead of complex approver logic + +--- + +## Getting help + +1. **Check this guide** - Most issues are covered above +2. **Check the reference** - Variable availability varies by context +3. **Preview first** - Always preview dynamic groups before saving +4. **Test incrementally** - Build complex expressions piece by piece diff --git a/product/admin/expressions-workflows.mdx b/product/admin/expressions-workflows.mdx new file mode 100644 index 0000000..e676006 --- /dev/null +++ b/product/admin/expressions-workflows.mdx @@ -0,0 +1,326 @@ +--- +title: Workflow expressions +og:title: Workflow expressions - ConductorOne docs +og:description: Use CEL expressions in workflows to pass data between steps, access trigger context, and build dynamic automations. +description: Use CEL expressions in workflows to pass data between steps, access trigger context, and build dynamic automations. +--- + +Workflow expressions let you pass data between steps in ConductorOne automations - like threading a user's email from a trigger into a lookup step, then into a notification. This is the most powerful CEL context because data flows through multiple steps, and each step can access outputs from all previous steps. + +## Core concept: The ctx object + +All workflow expressions access data through the `ctx` object, which contains: + +| Path | Description | +|:-----|:------------| +| `ctx.trigger` | Data from the event that started the workflow | +| `ctx.` | Output data from a completed step | + +Steps can only access data from **previously completed** steps. The `ctx` object grows as the workflow progresses - each step adds its output. + +```go +// In step 3, you can access: +ctx.trigger // Always available +ctx.step_one // If step_one completed before step 3 +ctx.step_two // If step_two completed before step 3 +// ctx.step_four // NOT available - hasn't run yet +``` + +--- + +## Template syntax + +Workflow expressions use double curly braces for interpolation: + +``` +{{ }} +``` + +The expression is evaluated and replaced with its result. + +### String templates + +``` +Hello {{ ctx.trigger.user.display_name }}! +``` + +Result: `Hello John Smith!` + +### JSON templates + +When the entire template is valid JSON after interpolation, it's parsed as a structured object: + +```json +{ + "name": "{{ ctx.trigger.user.display_name }}", + "email": "{{ ctx.step_one.email }}" +} +``` + +Result: A structured object with `name` and `email` fields accessible in later steps. + + +Template expressions are evaluated at runtime when the step executes. If a referenced field doesn't exist, the step will fail. + + +--- + +## Available variables + +### From trigger + +The trigger context varies by workflow trigger type. Common patterns: + +```go +// User who triggered the workflow +ctx.trigger.user.display_name +ctx.trigger.user.email +ctx.trigger.user_id + +// For user change triggers +// WRONG TRIGGER TYPE: Fails if this isn't a user-change trigger +ctx.trigger.oldUser.email +ctx.trigger.newUser.email + +// Custom trigger data +// DANGER: Fails if these fields don't exist - check has() first +ctx.trigger.source_ip +ctx.trigger.custom_field +``` + +### From previous steps + +Access output from any completed step by its step name: + +```go +// Assuming step_one completed with output containing user_id +// DANGER: Fails if step_one doesn't exist or didn't produce user_id +ctx.step_one.user_id + +// Array access from step output +ctx.lookup_step.users[0].email + +// Nested object access +ctx.api_call.response.data.id +``` + + +Step names in `ctx.` must match exactly. If you rename a step, update all references to it in later steps. + + +--- + +## Step data flow patterns + +### Pass user from trigger to lookup + +**Step 1 (trigger):** User change event fires + +**Step 2 (lookup):** Find user details +``` +{{ ctx.trigger.user_id }} +``` + +**Step 3 (action):** Use looked-up data +``` +{{ ctx.step_2.user.email }} +``` + +### Chain multiple lookups + +```go +// Step 1: Get user +ctx.trigger.user_id + +// Step 2: Get user's manager (uses step 1 output) +ctx.step_1.user.manager_id + +// Step 3: Get manager's email (uses step 2 output) +ctx.step_2.manager.email +``` + +### Conditional step execution + +Use CEL in step conditions to skip steps based on previous data: + +```go +// Only run this step if the user is a contractor +ctx.trigger.user.employment_type == "contractor" + +// Only run if previous step found results +size(ctx.lookup_step.results) > 0 + +// Only run if user changed departments +ctx.trigger.oldUser.department != ctx.trigger.newUser.department +``` + +--- + +## Common workflow expressions + +### User change detection + +```go +// Detect any email change +ctx.trigger.oldUser.email != ctx.trigger.newUser.email + +// Detect status change to disabled +ctx.trigger.oldUser.status == UserStatus.ENABLED && + ctx.trigger.newUser.status == UserStatus.DISABLED + +// Detect department change +ctx.trigger.oldUser.department != ctx.trigger.newUser.department +``` + +### Building notification messages + +``` +User {{ ctx.trigger.user.display_name }} ({{ ctx.trigger.user.email }}) +has been {{ ctx.trigger.newUser.status == UserStatus.DISABLED ? "disabled" : "updated" }}. +``` + +### Extracting data for API calls + +```json +{ + "user_id": "{{ ctx.trigger.user_id }}", + "action": "{{ ctx.trigger.action_type }}", + "timestamp": "{{ ctx.trigger.timestamp }}" +} +``` + +### Safe field access with has() + +```go +// Check if field exists before using it +has(ctx.trigger.user.manager_id) ? ctx.trigger.user.manager_id : "no-manager" + +// Check nested fields +has(ctx.trigger.user.profile) && has(ctx.trigger.user.profile.cost_center) + ? ctx.trigger.user.profile.cost_center + : "unknown" +``` + +--- + +## What can go wrong + +### Referencing undefined step + +**Error:** Step fails with "undefined reference" + +**Cause:** Referencing a step that hasn't run yet or doesn't exist. + +```go +// BAD: step_five hasn't completed yet (you're in step 3) +ctx.step_five.result + +// BAD: Typo in step name +ctx.step_on.result // Should be step_one +``` + +**Solution:** Only reference steps that complete before the current step. Check step names match exactly. + +### Wrong trigger type + +**Error:** Field not found in trigger + +**Cause:** Using user-change fields when trigger is account-change (or vice versa). + +```go +// FAILS if this is an account trigger, not a user trigger +ctx.trigger.oldUser.email + +// Use the right object for your trigger type +ctx.trigger.oldAccount.status // For account triggers +``` + +### Template syntax errors + +**Error:** `{{ERROR}}` appears in output + +**Cause:** Malformed template expression. + +```go +// BAD: Missing closing braces +{{ ctx.trigger.user.email } + +// BAD: Extra spaces inside braces (depends on parser) +{{ ctx.trigger.user.email }} + +// GOOD: Clean syntax +{{ ctx.trigger.user.email }} +``` + +### Null reference in chain + +**Error:** Step fails partway through + +**Cause:** Intermediate value is null. + +```go +// DANGER: Fails if user has no manager +ctx.step_1.user.manager.email + +// SAFE: Check each level +has(ctx.step_1.user.manager) ? ctx.step_1.user.manager.email : "no-manager@company.com" +``` + +--- + +## Best practices + +### Name steps clearly + +Use descriptive step names that indicate what they do: + +``` +lookup_user (not step_1) +get_manager (not step_2) +send_notification (not step_3) +``` + +This makes expressions self-documenting: +```go +ctx.lookup_user.email // Clear +ctx.get_manager.display_name // Clear +ctx.step_1.email // Unclear +``` + +### Check for nulls in chains + +When accessing nested data across steps: + +```go +// Build up safely +has(ctx.trigger.user) && +has(ctx.trigger.user.profile) && +has(ctx.trigger.user.profile.cost_center) + ? ctx.trigger.user.profile.cost_center + : "default" +``` + +### Use step conditions to skip gracefully + +Instead of failing on missing data, use step conditions: + +```go +// Step condition: Only run if user has a manager +has(ctx.trigger.user.manager_id) && ctx.trigger.user.manager_id != "" +``` + +### Test with representative data + +Before deploying: +1. Identify the trigger type and what data it provides +2. Trace the data flow through each step +3. Check for potential null values at each step +4. Verify step execution order + +--- + +## Related documentation + +- [Automations overview](/product/admin/automations) - Creating and managing automations +- [Expressions reference](/product/admin/expressions-reference) - All available objects and functions +- [Troubleshooting expressions](/product/admin/expressions-troubleshooting) - Debug common errors diff --git a/product/admin/expressions.mdx b/product/admin/expressions.mdx index 12eb9e7..b7dafb5 100644 --- a/product/admin/expressions.mdx +++ b/product/admin/expressions.mdx @@ -1,15 +1,17 @@ --- title: Write condition expressions og:title: Write condition expressions - ConductorOne docs -og:description: Write Common Expression Language (CEL) expressions to create powerful rules for ConductorOne policies and groups. -description: Write Common Expression Language (CEL) expressions to create powerful rules for ConductorOne policies and groups. +og:description: Write Common Expression Language (CEL) expressions to create powerful rules for ConductorOne policies and groups. +description: Write Common Expression Language (CEL) expressions to create powerful rules for ConductorOne policies and groups. --- -{/* Editor Refresh: 2026-01-07 */} +{/* Editor Refresh: 2026-01-21 */} ## What are CEL expressions and why use them? CEL (Common Expression Language) expressions are powerful, flexible rules that let you automate decision-making across ConductorOne. Instead of manually configuring each policy, group, or automation, you can write expressions that automatically adapt to your organization's unique needs. +CEL is an open-source expression language created by Google. It's the same technology behind Firebase Rules, Google Cloud IAM conditions, and Kubernetes admission webhooks. ConductorOne extends standard CEL with custom functions for directory lookups, user queries, and access management. + ### Why use CEL expressions? **Automate complex logic:** Create sophisticated rules that would be impossible with simple dropdowns or checkboxes. @@ -22,7 +24,59 @@ CEL (Common Expression Language) expressions are powerful, flexible rules that l **Integrate with your data:** Leverage user attributes, directory information, and access patterns to make intelligent decisions. -## Where CEL expressions are used in ConductorOne +--- + +## Where CEL expressions are used + +ConductorOne uses CEL expressions in many contexts. Each context provides different variables and expects a specific return type. + +### Primary contexts + +| Context | Returns | What it enables | +|:--------|:--------|:----------------| +| **Policy conditions** | true/false | Route requests to different approval workflows based on user, entitlement, or request properties | +| **Dynamic groups** | true/false | Automatically maintain group membership as users change departments, titles, or attributes | +| **Policy step approvers** | One or more users | Dynamically select approvers based on manager chains, app owners, or entitlement membership | + +### All expression contexts + +| Context | Returns | What it enables | +|:--------|:--------|:----------------| +| **Access review filters** | true/false | Scope certification campaigns to specific users or accounts | +| **Automation triggers** | true/false | Fire automations when user or account attributes change | +| **Automation steps** | varies | Template interpolation and step-to-step data flow | +| **Push config filters** | true/false | Target users for push rule provisioning | +| **Account provisioning** | text | Compute dynamic account attributes during grants | +| **User attribute mapping** | text or list of text | Derive user attributes from existing data | + + +Each context provides different variables. For example, `subject` is available in most contexts, but `ctx.trigger` is only available in automations. See the [expressions reference](/product/admin/expressions-reference) for details. + + +--- + +## How expressions work + +When you save an expression, ConductorOne validates it immediately. This catches most errors before they can cause problems: + +**Caught when you save:** +- Syntax errors (missing quotes, parentheses) +- Undefined variables (typos, wrong context) +- Type mismatches (comparing string to number) +- Wrong return type (returning a user when true/false is expected) + +**Only visible at runtime:** +- Empty results (looking up a user who doesn't exist) +- Empty lists (user has no manager) +- Missing profile fields + + +Runtime issues are subtle. For example, if an approver expression returns an empty list because the user has no manager, the approval step is **silently skipped** rather than failing. See [troubleshooting](/product/admin/expressions-troubleshooting) for common issues and solutions. + + +--- + +## Context details ### Policies - Automate access decisions @@ -32,11 +86,11 @@ CEL expressions power two critical parts of [policies](/product/admin/policies): A policy's details view, showing the policy conditionals and expressions. -**Policy conditionals** determine what action a policy will take (approve, deny, or route for review). These expressions must return a Boolean value. +**Policy conditions** determine what action a policy will take (approve, deny, or route for review). These expressions must return true or false. *Example: Automatically approve access for employees in the Engineering department, but require manager approval for contractors.* -**Policy expressions** determine who will be assigned to review a task. These expressions must return a list of users. +**Policy step approvers** determine who will be assigned to review a task. These expressions must return one or more users. *Example: Route access requests from contractors to their manager, while employees can self-approve certain low-risk access.* @@ -48,7 +102,7 @@ Use CEL expressions to define membership for [ConductorOne groups](/product/admi A group's details view, showing the group expressions. -**Group expressions** automatically determine group membership based on user attributes and conditions. These expressions must return a list of users. +**Group expressions** automatically determine group membership based on user attributes and conditions. These expressions must return true or false - true means the user is included in the group. *Example: Create a group that automatically includes all Engineering employees who are full-time and active.* @@ -58,17 +112,17 @@ Fine-tune [automations](/product/admin/automations) with CEL expressions to cont **Automation triggers** determine when an automation should start based on user changes, access events, or other conditions. -**Automation steps** can include conditional logic to skip steps or modify behavior based on user data. +**Automation steps** can include conditional logic to skip steps or modify behavior based on user data. See [workflow expressions](/product/admin/expressions-workflows) for details on passing data between steps. *Example: Automatically revoke access for users who haven't logged in for 45 days, but only for non-critical applications.* -### Campaigns - Precisely target access reviews +### Campaigns - Precisely target access reviews Use CEL expressions in [access review campaigns](/product/admin/campaigns) to precisely define which users, accounts, or access grants should be reviewed: -**User selection** expressions define which users should be included in the campaign. +**User scope** expressions filter which users should be included in the campaign. -**Account parameters** expressions filter which app accounts should be reviewed. +**Account scope** expressions filter which app accounts should be reviewed. *Example: Review access for all contractors in the Engineering department who have been granted access to production systems.* @@ -76,11 +130,22 @@ Use CEL expressions in [access review campaigns](/product/admin/campaigns) to pr When [configuring account provisioning](/product/admin/account-provisioning), CEL expressions transform your user data to match the requirements of target applications: -*Example: Map a user's full name from your directory to the first name and last name fields required by a target application.* +*Example: Derive a username from the user's email address by extracting the part before the @ symbol.* + +--- -## Ready to start writing CEL expressions? +## Next steps -- **[Object and function reference](/product/admin/expressions-reference)** - Complete reference for all available objects, functions, and their usage (including time functions) +- **[Expressions reference](/product/admin/expressions-reference)** - Complete reference for all available objects, functions, and their usage - **[Examples and patterns](/product/admin/expressions-examples)** - Practical examples, common patterns, and real-world use cases +- **[Workflow expressions](/product/admin/expressions-workflows)** - Pass data between automation steps using the ctx object + +- **[Troubleshooting](/product/admin/expressions-troubleshooting)** - Debug common errors and understand failure modes + + diff --git a/product/admin/rap/connectors-bcel.md b/product/admin/rap/connectors-bcel.md new file mode 100644 index 0000000..d7c47b7 --- /dev/null +++ b/product/admin/rap/connectors-bcel.md @@ -0,0 +1,127 @@ +# CEL in Baton Connectors + +Baton connectors (baton-http, baton-sql) use CEL for data transformation - mapping external data to Baton's resource model. This is different from policy CEL. + +## Key Differences from Policy CEL + +| Aspect | Policy CEL | Connector CEL | +|--------|------------|---------------| +| Purpose | Authorization decisions | Data transformation | +| Variables | `subject`, `entitlement`, `task` | `item`/`cols`, `response`, `resource` | +| Return types | `bool`, `User`, `list` | Any (strings, maps, lists) | +| Functions | `c1.directory.*`, `c1.user.*` | `Slugify`, `ToLower`, `json_path` | +| Configuration | UI, Terraform | YAML config files | + +--- + +## baton-http + +Maps HTTP API responses to Baton resources. + +### Variables + +| Variable | Description | +|----------|-------------| +| `item` | Current item from API response array | +| `response` | Full HTTP response body | +| `headers` | HTTP response headers | +| `resource` | Current Baton resource context | + +### Expression Prefix + +```yaml +id: cel:item.id # CEL expression +url: tmpl:/users/{{.id}} # Go template +name: "literal value" # Literal string +``` + +### Example Configuration + +```yaml +resource_types: + user: + list: + request: + url: /api/users + response: + items_path: data.users + resource_mapping: + id: cel:item.id + display_name: cel:item.firstName + " " + item.lastName + traits: + user: + status: cel:item.active == true ? "enabled" : "disabled" + emails: + - cel:item.email +``` + +### Common Patterns + +```yaml +# Conditional value +status: cel:item.status == "active" ? "enabled" : "disabled" + +# Null-safe with has() +manager_id: cel:has(item.manager_id) ? item.manager_id : "" + +# Type conversion +id: cel:string(item.numeric_id) +``` + +--- + +## baton-sql + +Maps SQL query results to Baton resources. + +### Variables + +| Variable | Description | +|----------|-------------| +| `cols` | Map of column names to values from current row | +| `resource` | Current Baton resource context | +| `principal` | User/entity for provisioning | +| `entitlement` | Entitlement for provisioning | + +### Example Configuration + +```yaml +resource_types: + user: + list: + query: | + SELECT id, username, email, is_active + FROM users WHERE deleted_at IS NULL + mapping: + id: cel:cols.id + display_name: cel:cols.username + traits: + user: + status: cel:cols.is_active == 1 ? "enabled" : "disabled" +``` + +### SQL-Specific Functions + +| Function | Description | +|----------|-------------| +| `Slugify(string)` | Convert to URL-safe slug | +| `ToLower(string)` | Lowercase | +| `ToUpper(string)` | Uppercase | +| `TitleCase(string)` | Title case | +| `PHPDeserializeStringArray(string)` | Parse PHP serialized arrays | + +--- + +## Null Handling + +API/database responses often contain null values. The `has()` macro returns `true` for fields that exist but are null: + +```cel +// has() returns true for null fields +has(item.manager_id) // true even if manager_id is null + +// Value will be empty string after coercion +item.manager_id // null -> "" +``` + +For connector CEL, null values are coerced to empty strings automatically. diff --git a/product/admin/rap/debug-errors.md b/product/admin/rap/debug-errors.md new file mode 100644 index 0000000..3df0fdb --- /dev/null +++ b/product/admin/rap/debug-errors.md @@ -0,0 +1,148 @@ +# CEL Error Reference + +## Error Types + +| Type | When Caught | Examples | +|------|-------------|----------| +| **Compile-time** | When you save | Syntax errors, type mismatches, undefined variables | +| **Runtime** | When expression evaluates | Empty lists, missing users, function failures | + +Compile-time errors prevent saving. Runtime errors cause unexpected behavior. + +--- + +## Compile-Time Errors + +### Syntax Error + +**Error:** `Syntax error: mismatched input` + +```cel +// BAD: single quotes not valid +subject.department == 'Engineering' + +// GOOD: use double quotes +subject.department == "Engineering" +``` + +### Undefined Variable + +**Error:** `undeclared reference to 'xyz'` + +```cel +// BAD: typo +subjct.department == "Engineering" + +// BAD: wrong environment +ctx.trigger.user_id // ctx only in workflows + +// GOOD +subject.department == "Engineering" +``` + +### Type Mismatch + +**Error:** `found no matching overload` + +```cel +// BAD: comparing string to number +subject.profile["level"] > 5 // profile values are strings + +// GOOD: convert types +int(subject.profile["level"]) > 5 +``` + +### Wrong Return Type + +**Error:** `expected type 'bool' but found 'string'` + +Policy conditions must return `bool`. Approver expressions must return `User` or `list`. + +--- + +## Runtime Errors + +### Empty Approver List + +**Symptom:** Policy step is skipped unexpectedly. + +**Cause:** Approver expression returned `[]`. + +```cel +// Problem: user has no manager +c1.directory.users.v1.GetManagers(subject) // returns [] + +// Solution: add fallback +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? c1.directory.users.v1.GetManagers(subject) + : appOwners +``` + +### User Not Found + +**Symptom:** `FindByEmail` or `GetByID` fails. + +**Cause:** User doesn't exist or left company. + +```cel +// Risky: hardcoded email +[c1.directory.users.v1.FindByEmail("john@company.com")] + +// Better: use entitlement-based groups +c1.directory.apps.v1.GetEntitlementMembers("app", "approvers") +``` + +### Profile Key Missing + +**Symptom:** Expression fails for some users. + +**Cause:** Profile key doesn't exist. + +```cel +// BAD: fails if costCenter missing +subject.profile["costCenter"] == "CC-123" + +// GOOD: check first +has(subject.profile.costCenter) && subject.profile["costCenter"] == "CC-123" +``` + +### Empty vs Missing + +**Symptom:** `has()` returns `true` but value is empty. + +**Cause:** `has()` checks existence, not emptiness. + +```cel +// has() returns true for empty strings +has(subject.department) // true even if department == "" + +// Check for non-empty +has(subject.department) && subject.department != "" + +// Or use ifEmpty for defaults +subject.department.ifEmpty("Unknown") +``` + +--- + +## Debugging Strategies + +### 1. Check Return Type + +Conditions must return `bool`. Approvers must return `User` or `list`. + +### 2. Test with Real Users + +Use the CEL validation service to see what an expression returns for actual users. + +### 3. Simplify + +Break complex expressions into smaller parts. Test each part. + +### 4. Check for Empty Lists + +Approver expressions that return `[]` skip the step. Always add fallbacks. + +### 5. Preview Before Saving + +For dynamic groups, preview who would be included before saving. diff --git a/product/admin/rap/env-access-reviews.md b/product/admin/rap/env-access-reviews.md new file mode 100644 index 0000000..d8a3c08 --- /dev/null +++ b/product/admin/rap/env-access-reviews.md @@ -0,0 +1,129 @@ +# Access Reviews Environment + +Filter which users or accounts are in scope for an access review. Evaluates to `bool`. + +## When to Use + +Use Access Reviews CEL for: +- Scoping reviews to specific departments or roles +- Filtering users by employment type or status +- Defining which accounts should be reviewed + +## Two Scopes + +Access reviews can operate at two levels: + +### User Scope + +Reviews the user identity across all their app accounts. + +| Variable | Type | Description | +|----------|------|-------------| +| `subject` | `User` | The user being evaluated for review | + +### Account Scope + +Reviews a specific app account. + +| Variable | Type | Description | +|----------|------|-------------| +| `subject` | `AppUser` | The app account being evaluated for review | + +## User Scope Patterns + +### Department-Based + +```cel +// Review all Engineering users +subject.department == "Engineering" + +// Multiple departments +subject.department in ["Engineering", "Product", "Design"] + +// Exclude certain departments +!(subject.department in ["Contractors", "Vendors"]) +``` + +### Job Title Patterns + +```cel +// Review all managers +subject.job_title.contains("Manager") + +// Case-insensitive match +subject.job_title.lowerAscii().contains("manager") +``` + +### Email Domain + +```cel +// Only internal users +subject.email.endsWith("@company.com") + +// Exclude contractors +!subject.email.endsWith("@contractor.company.com") +``` + +### Complex Filters + +```cel +// Engineering managers with company email +subject.email.endsWith("@company.com") && + subject.department == "Engineering" && + subject.job_title.contains("Manager") +``` + +## Account Scope Patterns + +### Status-Based + +```cel +// Only review enabled accounts +subject.status.status == Status.ENABLED + +// Only review disabled accounts (for cleanup review) +subject.status.status == Status.DISABLED +``` + +### Account Properties + +```cel +// Accounts with specific username pattern +subject.username.startsWith("svc_") + +// Accounts by email domain +subject.email.endsWith("@company.com") +``` + +### Status Details + +```cel +// Check status details for specific text +subject.status.details.contains("Active") +``` + +## What Can Go Wrong + +| Scenario | What Happens | How to Handle | +|----------|--------------|---------------| +| Using User fields on AppUser scope | Compile error or missing fields | Know which scope you're in | +| `subject.status` is nil | Status check returns false | May be intended; check `has(subject.status)` first | +| Complex expression always false | No users/accounts in review scope | Preview before saving | +| Complex expression always true | Everyone in scope | Defeats purpose of scoping | +| Case sensitivity | "Engineering" != "engineering" | Use `lowerAscii()` | + +## Functions Available + +Both scopes support directory and user functions: + +```cel +// Check if user has specific entitlement +c1.user.v1.HasEntitlement(subject, "app-id", "entitlement-id") + +// Check if user has access to app +c1.user.v1.HasApp(subject, "app-id") +``` + +## Best Practice + +Prefer simple expressions. Access review scopes run against many users/accounts - keep them fast. Use department or email domain filters over function calls when possible. diff --git a/product/admin/rap/env-account-provisioning.md b/product/admin/rap/env-account-provisioning.md new file mode 100644 index 0000000..739db72 --- /dev/null +++ b/product/admin/rap/env-account-provisioning.md @@ -0,0 +1,118 @@ +# Account Provisioning Expressions + +Generate dynamic values for account provisioning. Used to compute account attributes during grant operations. + +## When to Use + +Use Account Provisioning CEL for: +- Computing account username or email from user attributes +- Deriving account properties from entitlement or task context +- Building provisioning payloads with dynamic values + +## Available Variables + +| Variable | Type | Required | Description | +|----------|------|----------|-------------| +| `subject` | `User` | Yes | The user being provisioned | +| `entitlement` | `AppEntitlement` | Optional | The entitlement being granted | +| `task` | `Task` | Optional | The provisioning task context | + +## Common Patterns + +### Derive Account Properties from User + +```cel +// Email as username +subject.email + +// Build username from name +subject.display_name.lowerAscii().replace(" ", ".") + +// Department-prefixed username +subject.department.lowerAscii() + "_" + subject.username +``` + +### Use Profile Attributes + +```cel +// Employee ID from profile +subject.profile['employee_id'] + +// Cost center +subject.profile['cost_center'] + +// Location +subject.profile['office_location'] +``` + +### Use Attribute Mappings + +The `subject.attributes` field contains pre-fetched attribute mappings: + +```cel +// Access mapped attribute by display name +subject.attributes['Job Code'] + +// Use mapped attribute with fallback +has(subject.attributes['Team']) ? subject.attributes['Team'] : "Default" +``` + +### Conditional Values + +```cel +// Different value based on user type +subject.employment_type == "contractor" ? "ext_" + subject.username : subject.username + +// Based on entitlement (if available) +has(entitlement) && entitlement.app_resource_type == "Admin" + ? "admin_" + subject.username + : subject.username +``` + +### From Entitlement Context + +```cel +// Use entitlement properties +entitlement.app_resource_type + +// App-specific behavior +entitlement.app_id +``` + +### From Task Context + +```cel +// Grant duration awareness +task.is_grant_permanent ? "permanent" : "temporary" + +// Request origin +task.origin == TASK_ORIGIN_CERTIFY ? "recertified" : "new" +``` + +## Directory Functions + +Full directory library is available: + +```cel +// Get managers +c1.directory.users.v1.GetManagers(subject) + +// Check existing access +c1.user.v1.HasEntitlement(subject, "app-id", "entitlement-id") + +// Get app accounts +c1.user.v1.GetAppUsersForUser(subject, "app-id") +``` + +## What Can Go Wrong + +| Scenario | What Happens | How to Handle | +|----------|--------------|---------------| +| Profile attribute missing | Empty string or error | Use `has()` check | +| Attribute mapping not found | Empty value | Check mapping exists in C1 config | +| `entitlement` not available | Compile error if referenced | Check context provides entitlement | +| `task` not available | Compile error if referenced | Check context provides task | + +## Best Practice + +Keep expressions simple. The result becomes an account attribute, so ensure it's a clean string value appropriate for the target system. diff --git a/product/admin/rap/env-dynamic-groups.md b/product/admin/rap/env-dynamic-groups.md new file mode 100644 index 0000000..fa010ae --- /dev/null +++ b/product/admin/rap/env-dynamic-groups.md @@ -0,0 +1,95 @@ +# Dynamic Group Expressions + +Dynamic groups use CEL to determine membership. Expressions evaluate to `bool` - `true` means user is in the group. + +## Available Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `subject` | `User` | The user being evaluated for group membership | + +Only `subject` is available. No `entitlement` or `task`. + +## Common Patterns + +### Department-Based + +```cel +// Single department +subject.department == "Engineering" + +// Multiple departments +subject.department in ["Engineering", "Product", "Design"] + +// Exclude departments +!(subject.department in ["Contractors", "Vendors"]) +``` + +### Job Title Patterns + +```cel +// Simple contains +subject.job_title.contains("Manager") + +// Case-insensitive +subject.job_title.lowerAscii().contains("manager") + +// Multiple patterns +subject.job_title.contains("Director") || subject.job_title.contains("VP") +``` + +### Email Domain + +```cel +// Internal employees +subject.email.endsWith("@company.com") + +// Exclude contractors +!subject.email.endsWith("@contractor.company.com") +``` + +### Status-Based + +```cel +// Only enabled users +subject.status == USER_STATUS_ENABLED + +// Human users only (not service accounts) +subject.type == UserType.HUMAN +``` + +### Profile Attributes + +```cel +// Cost center from profile +has(subject.profile.costCenter) && subject.profile["costCenter"] == "CC-123" + +// Profile key with spaces (must use bracket notation) +"Cost Center" in subject.profile && subject.profile["Cost Center"] == "R&D" + +// Numeric threshold +has(subject.profile.level) && subject.profile.level >= 6 +``` + +### Manager Relationship + +```cel +// Direct reports of specific manager +subject.manager_id == "manager-user-id" + +// Users who have a manager assigned +has(subject.manager_id) +``` + +## What Can Go Wrong + +| Scenario | What Happens | How to Handle | +|----------|--------------|---------------| +| Empty department | User excluded from group | May be intentional | +| Expression always returns `false` | Group is empty | Preview before saving | +| Expression always returns `true` | Everyone in group | Probably wrong | +| Profile key doesn't exist | Runtime error | Use `has()` or `in` check first | + +## Performance Note + +Dynamic group expressions are evaluated against every user in your directory. Keep expressions simple (string comparisons) rather than complex (function calls). diff --git a/product/admin/rap/env-policy-approvers.md b/product/admin/rap/env-policy-approvers.md new file mode 100644 index 0000000..0c6670c --- /dev/null +++ b/product/admin/rap/env-policy-approvers.md @@ -0,0 +1,97 @@ +# Policy Approver Expressions + +Policy step expressions determine who approves a request. They evaluate to `User` or `list`. + +## Available Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `subject` | `User` | The user requesting access | +| `appOwners` | `list` | The owners of the application | +| `entitlement` | `AppEntitlement` | The entitlement being requested | +| `task` | `Task` | The access request task/ticket | + +## Critical Behavior + +**An approver expression that returns an empty list causes the policy step to be skipped entirely.** This is often not the intended behavior. + +## Common Patterns + +### Manager Approval + +```cel +// Subject's manager(s) +c1.directory.users.v1.GetManagers(subject) + +// First manager only +[c1.directory.users.v1.GetManagers(subject)[0]] +``` + +**Safe pattern with fallback:** +```cel +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? c1.directory.users.v1.GetManagers(subject) + : appOwners +``` + +### App Owner Approval + +```cel +// All app owners +appOwners + +// First app owner +[appOwners[0]] +``` + +### Specific Person + +```cel +// By email +[c1.directory.users.v1.FindByEmail("security@company.com")] + +// By user ID +[c1.directory.users.v1.GetByID("user-abc123")] +``` + +### Entitlement Members + +```cel +// Members of an approval group +c1.directory.apps.v1.GetEntitlementMembers("approvers-app", "security-approvers") +``` + +### Skip-Level Approval + +```cel +// Manager's manager +c1.directory.users.v1.GetManagers( + c1.directory.users.v1.GetManagers(subject)[0] +) +``` + +### Conditional Approvers + +```cel +// App-specific managers if available, otherwise directory managers +size(c1.user.v1.GetAppUserManagers(subject, entitlement.app_id)) > 0 + ? c1.user.v1.GetAppUserManagers(subject, entitlement.app_id) + : c1.directory.users.v1.GetManagers(subject) +``` + +## What Can Go Wrong + +| Scenario | What Happens | How to Handle | +|----------|--------------|---------------| +| `GetManagers` returns `[]` | Step is skipped | Add fallback approver | +| `appOwners` is empty | Step is skipped | Ensure app has owners | +| `FindByEmail` user doesn't exist | Step fails | Verify email before deploying | +| User leaves company | Step fails | Use entitlement-based approvers | +| Accessing `[0]` on empty list | Index error | Check `size() > 0` first | + +## Best Practice + +Always have a fallback. Use the ternary pattern: +```cel +size(primaryApprovers) > 0 ? primaryApprovers : fallbackApprovers +``` diff --git a/product/admin/rap/env-policy-conditions.md b/product/admin/rap/env-policy-conditions.md new file mode 100644 index 0000000..949fbf8 --- /dev/null +++ b/product/admin/rap/env-policy-conditions.md @@ -0,0 +1,75 @@ +# Policy Condition Expressions + +Policy conditions determine which policy step applies to a request. They evaluate to `bool`. + +## Available Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `subject` | `User` | The user requesting access | +| `entitlement` | `AppEntitlement` | The entitlement being requested | +| `task` | `Task` | The access request task/ticket | + +## Common Patterns + +### Route by User Attribute + +```cel +// By department +subject.department == "Engineering" + +// By employment type +subject.employment_type == "contractor" + +// By job title (contains) +subject.job_title.contains("Manager") +``` + +### Route by Entitlement + +```cel +// By resource type +entitlement.app_resource_type == "Production" + +// By specific entitlement ID +entitlement.id == "admin-entitlement-id" + +// By app +entitlement.app_id == "production-database-app" +``` + +### Route by Request Properties + +```cel +// Permanent grants need extra approval +task.is_grant_permanent == true + +// Long-term grants (over 30 days) +task.grant_duration > duration("720h") + +// Request origin +task.origin == TASK_ORIGIN_CERTIFY +task.origin == TASK_ORIGIN_REQUEST +``` + +### Compound Conditions + +```cel +// Contractor + production = escalated +subject.employment_type == "contractor" && entitlement.app_resource_type == "Production" + +// Either sensitive app OR permanent grant +entitlement.app_id == "high-security-app" || task.is_grant_permanent +``` + +## What Can Go Wrong + +| Scenario | What Happens | How to Handle | +|----------|--------------|---------------| +| `subject.department` is empty | Comparison returns `false` | May be intentional; use `has()` for explicit check | +| Typo in field name | Compile-time error | Expression won't save | +| Complex boolean logic | Hard to debug | Use multiple policy rules instead | + +## Best Practice + +Keep conditions simple. Use multiple policy rules with simple conditions rather than one rule with complex compound logic. diff --git a/product/admin/rap/env-push-filter.md b/product/admin/rap/env-push-filter.md new file mode 100644 index 0000000..37c3ac9 --- /dev/null +++ b/product/admin/rap/env-push-filter.md @@ -0,0 +1,113 @@ +# Push Config Filter Environment + +Filter which users are targeted by push rules. Evaluates to `bool`. + +## When to Use + +Use Push Config Filter CEL for: +- Targeting entitlement grants to specific user groups +- Filtering who receives automatically provisioned access +- Defining user criteria for push-based provisioning + +## Available Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `subject` | `User` | The user being evaluated for push rule targeting | + +## Common Patterns + +### Department-Based Targeting + +```cel +// Push to Engineering +subject.department == "Engineering" + +// Push to multiple departments +subject.department in ["Engineering", "Product"] +``` + +### Status-Based + +```cel +// Only enabled users +subject.status == UserStatus.ENABLED + +// Exclude disabled users +subject.status != UserStatus.DISABLED +``` + +### Profile Attributes + +```cel +// Users in specific location +subject.profile['location'] == 'San Francisco' + +// Users with specific cost center +subject.profile['department'] == 'Engineering' + +// Multiple profile conditions +subject.profile['department'] == 'Engineering' && + subject.profile['location'] == 'San Francisco' +``` + +### Email Domain + +```cel +// Only company domain +subject.email.endsWith('@company.com') + +// Starts with specific prefix +subject.email.startsWith('admin') +``` + +### String Operations + +```cel +// Display name contains +subject.display_name.contains('John') + +// Case-insensitive check +subject.email.lowerAscii().endsWith('@company.com') + +// Length check +subject.username.size() > 5 +``` + +### Compound Conditions + +```cel +// Engineering + enabled + company email +subject.department == 'Engineering' && + subject.status == UserStatus.ENABLED && + subject.email.endsWith('@company.com') + +// Either condition (or) +subject.department == 'IT' || subject.department == 'Security' +``` + +## What Can Go Wrong + +| Scenario | What Happens | How to Handle | +|----------|--------------|---------------| +| Profile key doesn't exist | Runtime error or empty value | Use `has()` or `in` check | +| Expression always true | Everyone gets pushed | Probably not intended | +| Expression always false | No one gets pushed | Check logic | +| Typo in field name | Compile-time error | Expression won't save | +| Case mismatch | No match | Use `lowerAscii()` | + +## Functions Available + +Directory and user functions are available: + +```cel +// Users with specific entitlement +c1.user.v1.HasEntitlement(subject, "app-id", "entitlement-id") + +// Users with any access to app +c1.user.v1.HasApp(subject, "app-id") +``` + +## Best Practice + +Push filters run against your entire user directory. Keep expressions simple and fast - prefer field comparisons over function calls. diff --git a/product/admin/rap/env-triggers.md b/product/admin/rap/env-triggers.md new file mode 100644 index 0000000..a8fb5c9 --- /dev/null +++ b/product/admin/rap/env-triggers.md @@ -0,0 +1,82 @@ +# Triggers Environment + +Event-driven expressions that fire when users or accounts change. Evaluate to `bool`. + +## When to Use + +Use Triggers CEL for: +- Detecting attribute changes (email, status, department) +- Firing automations when users are modified +- Comparing old vs new state during sync + +## Available Variables + +Variables are nested under `ctx.trigger` with both snake_case and camelCase forms. + +### For User Changes + +| Variable | Type | Description | +|----------|------|-------------| +| `ctx.trigger.oldUser` / `ctx.trigger.old_user` | `User` | User state before change | +| `ctx.trigger.newUser` / `ctx.trigger.new_user` | `User` | User state after change | + +### For Account Changes + +| Variable | Type | Description | +|----------|------|-------------| +| `ctx.trigger.oldAccount` / `ctx.trigger.old_account` | `AppUser` | Account state before change | +| `ctx.trigger.newAccount` / `ctx.trigger.new_account` | `AppUser` | Account state after change | + +## Available Functions + +| Function | Returns | Description | +|----------|---------|-------------| +| `now()` | `timestamp` | Current time for date comparisons | + +## Common Patterns + +### Detect Field Change + +```cel +// Email changed +ctx.trigger.oldUser.email != ctx.trigger.newUser.email + +// Status changed to disabled +ctx.trigger.oldUser.status == UserStatus.ENABLED && + ctx.trigger.newUser.status == UserStatus.DISABLED + +// Account status change +ctx.trigger.oldAccount.status.status == Status.ENABLED && + ctx.trigger.newAccount.status.status == Status.DISABLED +``` + +### Date-Based Triggers + +```cel +// Hire date is today (fire onboarding) +timestamp(ctx.trigger.newUser.profile.hire_date).getDayOfYear() == + timestamp(now()).getDayOfYear() && +timestamp(ctx.trigger.newUser.profile.hire_date).getFullYear() == + timestamp(now()).getFullYear() +``` + +### Multiple Fields + +```cel +// Both email and department changed +ctx.trigger.oldUser.email != ctx.trigger.newUser.email && + ctx.trigger.oldUser.department != ctx.trigger.newUser.department +``` + +## What Can Go Wrong + +| Scenario | What Happens | How to Handle | +|----------|--------------|---------------| +| Typo in field name | Compile-time error | Expression won't save | +| Using User fields on AppUser | Wrong variable type | Check if trigger is for users or accounts | +| Profile field doesn't exist | Runtime error accessing it | Use `has()` check first | +| Comparing nested nil objects | Panic or unexpected result | Check parent exists: `has(ctx.trigger.newUser.status)` | + +## Best Practice + +Keep trigger conditions focused on a single change type. Use separate triggers for different events rather than one complex trigger that handles everything. diff --git a/product/admin/rap/env-user-attribute.md b/product/admin/rap/env-user-attribute.md new file mode 100644 index 0000000..4d26cfd --- /dev/null +++ b/product/admin/rap/env-user-attribute.md @@ -0,0 +1,120 @@ +# User Attribute Mapping Expressions + +Compute derived attribute values from user data. Returns `string` or `list`. + +## When to Use + +Use User Attribute Mapping CEL for: +- Deriving attributes from existing user fields +- Transforming profile data into standardized formats +- Computing values for downstream systems + +## Available Variables + +| Variable | Type | Description | +|----------|------|-------------| +| `subject` | `User` | The user whose attributes are being computed | + +## Return Type + +Expressions must return either: +- A single `string` +- A `list` (array of strings) + +Empty strings and empty lists are valid returns. + +## Common Patterns + +### Simple Field Access + +```cel +// Return email domain +subject.email.split("@")[1] + +// Return uppercase department +subject.department.upperAscii() + +// Return display name parts +subject.display_name.split(" ")[0] // First name +``` + +### Transform Profile Values + +```cel +// Normalize job code format +"JC-" + subject.profile['raw_job_code'] + +// Combine profile fields +subject.profile['city'] + ", " + subject.profile['state'] +``` + +### Conditional Mapping + +```cel +// Map employment type to category +subject.employment_type == "contractor" ? "external" : "internal" + +// Map department to cost center prefix +subject.department == "Engineering" ? "ENG" + : subject.department == "Sales" ? "SLS" + : "OTH" +``` + +### Return List + +```cel +// Return all email domains user might use +[subject.email.split("@")[1], "company.com"] + +// Return multiple values based on profile +has(subject.profile['secondary_email']) + ? [subject.email, subject.profile['secondary_email']] + : [subject.email] +``` + +### String Operations + +```cel +// Remove whitespace and lowercase +subject.job_title.lowerAscii().trim() + +// Replace characters +subject.username.replace(".", "_") + +// Check and transform +subject.email.contains("@company.com") + ? subject.email.replace("@company.com", "@corp.com") + : subject.email +``` + +## Directory Functions + +Directory functions are available: + +```cel +// Get manager's department +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? c1.directory.users.v1.GetManagers(subject)[0].department + : "No Manager" + +// Check entitlement membership +c1.user.v1.HasEntitlement(subject, "app-id", "admin-role") + ? "admin" + : "user" +``` + +## What Can Go Wrong + +| Scenario | What Happens | How to Handle | +|----------|--------------|---------------| +| Profile field missing | Empty string | Use `has()` check | +| Array index out of bounds | Runtime error | Check `size()` first | +| String split with no delimiter | Single-element array | Handle single-element case | +| Wrong return type | Conversion error | Ensure string or list | + +## Best Practice + +- Return clean, normalized values suitable for downstream systems +- Handle missing data gracefully with defaults +- Keep expressions simple and testable +- Document expected input/output formats diff --git a/product/admin/rap/env-workflow.md b/product/admin/rap/env-workflow.md new file mode 100644 index 0000000..1551f5e --- /dev/null +++ b/product/admin/rap/env-workflow.md @@ -0,0 +1,124 @@ +# Workflow Execution Expressions + +Dynamic data access within ConductorOne workflow automations. Supports template interpolation and step-to-step data flow. + +## When to Use + +Use Workflow CEL for: +- Accessing trigger data in workflow steps +- Passing data between workflow steps +- Building dynamic messages and payloads +- Conditional workflow logic + +## The `ctx` Object + +All workflow expressions use the `ctx` object: + +| Path | Description | +|------|-------------| +| `ctx.trigger` | Data from the event that started the workflow | +| `ctx.` | Output from a completed step | + +## Template Syntax + +Use double curly braces for interpolation: + +``` +Hello {{ ctx.trigger.user.display_name }}! +``` + +## Common Patterns + +### Access Trigger Data + +```cel +// User information +ctx.trigger.user.display_name +ctx.trigger.user.email +ctx.trigger.user_id + +// Custom trigger fields +ctx.trigger.custom_field +``` + +### Access Previous Step Output + +```cel +// Single value +ctx.lookup_step.user_id + +// Array +ctx.approval_step.user_ids + +// Nested +ctx.manager_lookup.user.manager.email +``` + +### Build JSON Output + +```json +{ + "name": "{{ ctx.trigger.user.display_name }}", + "email": "{{ ctx.step_one.email }}" +} +``` + +This becomes a struct accessible by later steps. + +### Conditional Values + +```cel +{{ ctx.trigger.status == "approved" ? "Approved!" : "Denied" }} +``` + +### Directory Functions + +```cel +// Look up user +c1.directory.users.v1.GetByID(ctx.trigger.user_id) + +// Get managers +c1.directory.users.v1.GetManagers(ctx.trigger.user) + +// Find by email +c1.directory.users.v1.FindByEmail(ctx.step_one.email) +``` + +## What Can Go Wrong + +| Scenario | What Happens | How to Handle | +|----------|--------------|---------------| +| Reference undefined step | Compile error | Check step order and names | +| Access missing field | Runtime error | Use `has()` check or ensure upstream produces field | +| Template syntax error | Error in output | Check matching braces | +| Output too large (>1KB) | Truncated | Keep outputs small | + +## Safe Field Access + +```cel +// Unsafe - fails if optional_field is missing +ctx.trigger.optional_field.value + +// Safe - provide default +has(ctx.trigger.optional_field) ? ctx.trigger.optional_field.value : "default" +``` + +## Time Functions + +```cel +// Current time +now() + +// Parse date +time.parse(ctx.trigger.date_field, "date") + +// End of quarter +time.end_of(now(), "quarter") +``` + +## Best Practice + +- Use descriptive step names (`lookup_manager`, not `step1`) +- Keep step outputs small and focused +- Test expressions before deploying +- Always handle optional fields with `has()` diff --git a/product/admin/rap/functions-directory.md b/product/admin/rap/functions-directory.md new file mode 100644 index 0000000..ca96980 --- /dev/null +++ b/product/admin/rap/functions-directory.md @@ -0,0 +1,124 @@ +# Directory Functions + +Functions in the `c1.directory.*` namespace for looking up users and relationships. + +## GetManagers + +Get a user's manager(s). + +```cel +c1.directory.users.v1.GetManagers(user: User) -> list +``` + +**Example:** +```cel +// Get subject's managers as approvers +c1.directory.users.v1.GetManagers(subject) + +// Safe access with fallback +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? c1.directory.users.v1.GetManagers(subject) + : appOwners +``` + +**Returns:** Empty list `[]` if user has no manager. + +--- + +## FindByEmail + +Look up a user by email address. + +```cel +c1.directory.users.v1.FindByEmail(email: string) -> User +``` + +**Example:** +```cel +// Specific approver +[c1.directory.users.v1.FindByEmail("security@company.com")] +``` + +**Fails** if email doesn't exist. Verify email before deploying. + +--- + +## GetByID + +Look up a user by their ID. + +```cel +c1.directory.users.v1.GetByID(id: string) -> User +``` + +**Example:** +```cel +[c1.directory.users.v1.GetByID("user-abc123")] +``` + +**Fails** if user ID doesn't exist. + +--- + +## DirectReports + +Get users who report to the given user(s). + +```cel +c1.directory.users.v1.DirectReports(managers: list) -> list +``` + +**Example:** +```cel +// Manager can delegate to their direct reports +c1.directory.users.v1.DirectReports(appOwners) +``` + +--- + +## GetEntitlementMembers + +Get members of a specific entitlement (useful for approval groups). + +```cel +c1.directory.apps.v1.GetEntitlementMembers(appId: string, entitlementSlug: string) -> list +``` + +**Example:** +```cel +// Security team as approvers +c1.directory.apps.v1.GetEntitlementMembers("approvers-app", "security-reviewers") +``` + +**Returns:** Empty list `[]` if entitlement has no members (step will be skipped). + +--- + +## Common Patterns + +### Manager with Fallback + +```cel +size(c1.directory.users.v1.GetManagers(subject)) > 0 + ? c1.directory.users.v1.GetManagers(subject) + : appOwners +``` + +### Skip-Level Approval + +```cel +c1.directory.users.v1.GetManagers( + c1.directory.users.v1.GetManagers(subject)[0] +) +``` + +**Warning:** Fails if subject has no manager. Add null checks. + +### Entitlement-Based Approvers + +Prefer entitlement members over hardcoded emails - handles employee turnover: + +```cel +// Better than FindByEmail("john@company.com") +c1.directory.apps.v1.GetEntitlementMembers("approval-app", "security-reviewers") +``` diff --git a/product/admin/rap/functions-ip.md b/product/admin/rap/functions-ip.md new file mode 100644 index 0000000..aa58f91 --- /dev/null +++ b/product/admin/rap/functions-ip.md @@ -0,0 +1,86 @@ +# IP/CIDR Functions + +Functions for network-based access control using IP addresses and CIDR ranges. + +## ip() + +Create an IP address object from a string. + +```cel +ip(address: string) -> IPAddr +``` + +**Example:** +```cel +ip("192.168.1.100") +ip(ctx.trigger.source_ip) +``` + +--- + +## cidr() + +Create a CIDR range or set of ranges. + +```cel +cidr(range: string) -> CIDRSet +cidr(ranges: list) -> CIDRSet +cidr(range1: string, range2: string, ...) -> CIDRSet +``` + +**Example:** +```cel +// Single range +cidr("10.0.0.0/8") + +// Multiple ranges +cidr("10.0.0.0/8", "192.168.0.0/16") +cidr(["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]) +``` + +--- + +## contains() + +Check if an IP is within a CIDR range. + +```cel +cidrSet.contains(ip: IPAddr) -> bool +``` + +**Example:** +```cel +// Check if request is from corporate network +cidr("10.0.0.0/8").contains(ip(ctx.trigger.source_ip)) +``` + +--- + +## Common Patterns + +### Corporate Network Check + +```cel +has(ctx.trigger.source_ip) && + cidr("10.0.0.0/8", "192.168.0.0/16").contains(ip(ctx.trigger.source_ip)) +``` + +### RFC 1918 Private Addresses + +```cel +cidr(["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]).contains(ip(source_ip)) +``` + +### VPN/Office Detection + +```cel +// Requests from VPN get different approval flow +has(ctx.trigger.source_ip) && + cidr("10.50.0.0/16").contains(ip(ctx.trigger.source_ip)) +``` + +--- + +## Availability + +IP/CIDR functions are available in workflow/automation contexts where `ctx.trigger.source_ip` provides the request origin. Not typically used in dynamic groups or basic policy conditions. diff --git a/product/admin/rap/functions-strings.md b/product/admin/rap/functions-strings.md new file mode 100644 index 0000000..5ccbdcd --- /dev/null +++ b/product/admin/rap/functions-strings.md @@ -0,0 +1,125 @@ +# String Functions + +Standard CEL string methods plus ConductorOne extensions. + +## ifEmpty() + +Return a default value if string is empty. ConductorOne extension. + +```cel +string.ifEmpty(default: string) -> string +``` + +**Example:** +```cel +// Default for empty department +subject.department.ifEmpty("Unknown") + +// Chain with other operations +subject.profile["team"].ifEmpty("Unassigned").lowerAscii() +``` + +--- + +## contains() + +Check if string contains a substring. + +```cel +string.contains(substring: string) -> bool +``` + +**Example:** +```cel +subject.job_title.contains("Manager") +subject.email.contains("@company.com") +``` + +--- + +## startsWith() / endsWith() + +Check string prefix or suffix. + +```cel +string.startsWith(prefix: string) -> bool +string.endsWith(suffix: string) -> bool +``` + +**Example:** +```cel +// Email domain check +subject.email.endsWith("@company.com") + +// Department prefix +subject.department.startsWith("Engineering") +``` + +--- + +## lowerAscii() / upperAscii() + +Convert to lowercase or uppercase (ASCII only). + +```cel +string.lowerAscii() -> string +string.upperAscii() -> string +``` + +**Example:** +```cel +// Case-insensitive comparison +subject.department.lowerAscii() == "engineering" + +// Case-insensitive contains +subject.job_title.lowerAscii().contains("manager") +``` + +--- + +## size() + +Get string length. + +```cel +size(string) -> int +``` + +**Example:** +```cel +size(subject.department) > 0 +``` + +--- + +## has() vs ifEmpty() + +| Scenario | Use | Why | +|----------|-----|-----| +| Field might not exist | `has()` | Prevents runtime error | +| Field exists but might be empty | `ifEmpty()` | Provides default for empty | +| Both | Combine | `has(x) ? x.ifEmpty("default") : "missing"` | + +`has()` checks existence. `ifEmpty()` handles empty strings. Different problems. + +--- + +## Common Patterns + +### Case-Insensitive Match + +```cel +subject.job_title.lowerAscii().contains("manager") +``` + +### Email Domain Check + +```cel +subject.email.lowerAscii().endsWith("@company.com") +``` + +### Safe Field Access with Default + +```cel +has(subject.department) ? subject.department.ifEmpty("Unknown") : "Not Set" +``` diff --git a/product/admin/rap/functions-time.md b/product/admin/rap/functions-time.md new file mode 100644 index 0000000..80eb183 --- /dev/null +++ b/product/admin/rap/functions-time.md @@ -0,0 +1,151 @@ +# Time Functions + +Functions for working with dates, times, and durations. + +## now() + +Get the current timestamp. + +```cel +now() -> timestamp +``` + +**Example:** +```cel +// Business hours check (UTC) +now().getHours() >= 9 && now().getHours() < 17 +``` + +--- + +## time.parse + +Parse a string into a timestamp. + +```cel +time.parse(value: string, layout: string) -> timestamp +time.parse(value: string, layout: string, timezone: string) -> timestamp +``` + +**Layout aliases:** `rfc3339`, `date`, `datetime`, `time` + +**Example:** +```cel +// Parse hire date from profile +time.parse(subject.profile["hire_date"], "date") + +// With timezone +time.parse("2024-01-15", "date", "America/New_York") +``` + +--- + +## time.format + +Format a timestamp as a string. + +```cel +time.format(ts: timestamp, layout: string) -> string +time.format(ts: timestamp, layout: string, timezone: string) -> string +``` + +**Example:** +```cel +// Format for display +time.format(now(), "datetime", "America/New_York") +``` + +--- + +## time.unix + +Create timestamp from Unix epoch seconds. + +```cel +time.unix(seconds: int) -> timestamp +``` + +**Example:** +```cel +// Parse Unix timestamp from profile +time.unix(int(subject.profile["last_login"])) > now() - duration("720h") +``` + +--- + +## time.start_of / time.truncate + +Truncate timestamp to start of a time unit. + +```cel +time.start_of(ts: timestamp, unit: string) -> timestamp +time.truncate(ts: timestamp, unit: string) -> timestamp +``` + +**Units:** `day`, `week`, `month`, `quarter`, `year`, `hour`, `minute` + +**Example:** +```cel +// Start of current quarter +time.start_of(now(), "quarter") +``` + +--- + +## time.end_of + +Get end of a time unit. + +```cel +time.end_of(ts: timestamp, unit: string) -> timestamp +``` + +**Example:** +```cel +// Access expires at end of quarter +time.end_of(now(), "quarter") +``` + +--- + +## duration() + +Create a duration value. + +```cel +duration(spec: string) -> duration +``` + +**Format:** `"Nh"` for hours, `"Nm"` for minutes, `"Ns"` for seconds + +**Example:** +```cel +// Grants over 30 days +task.grant_duration > duration("720h") + +// Recently hired (within 90 days) +time.parse(subject.profile["hire_date"], "date") > now() - duration("2160h") +``` + +--- + +## Common Patterns + +### Recently Hired Users + +```cel +has(subject.profile.hire_date) && + time.parse(subject.profile["hire_date"], "date") > now() - duration("2160h") +``` + +### Active Users (logged in within 30 days) + +```cel +time.unix(int(subject.profile["last_login"])) > now() - duration("720h") +``` + +### Business Hours + +```cel +now().getHours() >= 9 && now().getHours() < 17 +``` diff --git a/product/admin/rap/functions-user.md b/product/admin/rap/functions-user.md new file mode 100644 index 0000000..53e4ce4 --- /dev/null +++ b/product/admin/rap/functions-user.md @@ -0,0 +1,70 @@ +# User Functions + +Functions in the `c1.user.*` namespace for checking user attributes and entitlements. + +## HasEntitlement + +Check if a user has a specific entitlement. + +```cel +c1.user.v1.HasEntitlement(user: User, appId: string, entitlementSlug: string) -> bool +``` + +**Example:** +```cel +// Include users with admin access in a dynamic group +c1.user.v1.HasEntitlement(subject, "okta-app-id", "admin-role") + +// Policy condition: on-call users get fast-track +c1.user.v1.HasEntitlement(subject, "pagerduty", "on-call") +``` + +--- + +## HasApp + +Check if a user has any access to an application. + +```cel +c1.user.v1.HasApp(user: User, appId: string) -> bool +``` + +**Example:** +```cel +// Users with any access to critical app +c1.user.v1.HasApp(subject, "critical-app-id") +``` + +--- + +## GetAppUserManagers + +Get app-specific managers for a user (different from directory managers). + +```cel +c1.user.v1.GetAppUserManagers(user: User, appId: string) -> list +``` + +**Example:** +```cel +// Use app-specific managers if available +size(c1.user.v1.GetAppUserManagers(subject, entitlement.app_id)) > 0 + ? c1.user.v1.GetAppUserManagers(subject, entitlement.app_id) + : c1.directory.users.v1.GetManagers(subject) +``` + +--- + +## Performance Note + +Function calls are more expensive than simple field comparisons. In dynamic groups (evaluated against every user), prefer: + +```cel +// Faster: field comparison +subject.department == "Engineering" + +// Slower: function call +c1.user.v1.HasEntitlement(subject, "app", "role") +``` + +Use function calls when necessary, but be aware of the performance impact in high-volume contexts. diff --git a/product/admin/rap/index.md b/product/admin/rap/index.md new file mode 100644 index 0000000..7a09f18 --- /dev/null +++ b/product/admin/rap/index.md @@ -0,0 +1,94 @@ +# CEL Expression Knowledge Base + +This directory contains focused documentation chunks for CEL (Common Expression Language) in ConductorOne. Each file is self-contained and designed for selective retrieval. + +## How to Use This Index + +When answering questions about CEL expressions in ConductorOne: + +1. Identify the question type from the tables below +2. Retrieve 1-3 relevant files from this directory +3. Use the retrieved content to answer accurately +4. If the answer is incomplete, retrieve additional files + +## Available Knowledge Files + +### CEL Environments + +These files document where CEL expressions are used and what variables/functions are available in each context. + +| File | Use When User Asks About | +|------|--------------------------| +| `env-policy-conditions.md` | Policy condition expressions, routing requests, access control rules | +| `env-dynamic-groups.md` | Dynamic group membership, group expressions, user filtering | +| `env-policy-approvers.md` | Approver selection, manager routing, approval workflows | +| `env-triggers.md` | Automation triggers, detecting user/account changes, event-driven expressions | +| `env-access-reviews.md` | Access review scope, filtering users/accounts for certification | +| `env-push-filter.md` | Push rule targeting, user provisioning filters | +| `env-workflow.md` | Workflow templates, step data flow, ctx object, interpolation | +| `env-account-provisioning.md` | Account attribute mapping, provisioning expressions | +| `env-user-attribute.md` | Derived user attributes, computed attribute values | + +### Functions + +These files document specific function categories available in CEL expressions. + +| File | Use When User Asks About | +|------|--------------------------| +| `functions-directory.md` | GetManagers, FindByEmail, GetByID, DirectReports, GetEntitlementMembers | +| `functions-user.md` | HasEntitlement, HasApp, checking user access | +| `functions-time.md` | Date/time operations, time.parse, time.format, now(), durations | +| `functions-ip.md` | IP addresses, CIDR ranges, network-based access control | +| `functions-strings.md` | String operations, ifEmpty, contains, endsWith, lowerAscii | + +### Types and References + +| File | Use When User Asks About | +|------|--------------------------| +| `types.md` | Type definitions, enums, User vs AppUser, primitives, collections, built-in variables | + +### Patterns and Debugging + +| File | Use When User Asks About | +|------|--------------------------| +| `patterns-common.md` | Safe field access, null handling, list operations, ternary expressions | +| `debug-errors.md` | CEL errors, troubleshooting, compile-time vs runtime issues | +| `overview-intro.md` | What is CEL, basic concepts, getting started | + +### Integrations + +| File | Use When User Asks About | +|------|--------------------------| +| `terraform-examples.md` | CEL in Terraform, conductorone_policy resources, IaC patterns | +| `connectors-bcel.md` | CEL in baton-http, baton-sql, connector data transformation | + +## Quick Retrieval Guide + +**User wants to write an expression:** +- "Route to manager" -> `env-policy-approvers.md`, `functions-directory.md` +- "Create a group for contractors" -> `env-dynamic-groups.md`, `patterns-common.md` +- "Trigger when user disabled" -> `env-triggers.md` +- "Check if user has access" -> `functions-user.md` + +**User asks about types:** +- "What's the difference between User and AppUser?" -> `types.md` +- "What fields does subject have?" -> `types.md` +- "What are the UserStatus values?" -> `types.md` +- "How do I use duration?" -> `types.md`, `functions-time.md` +- "What enums are available?" -> `types.md` +- "What's in appOwners?" -> `types.md` + +**User has an error:** +- Any CEL error -> `debug-errors.md` first, then relevant env file +- Type mismatch error -> `types.md`, `debug-errors.md` + +**User asks what's available:** +- "What variables can I use?" -> `types.md` (built-in variables), then relevant `env-*.md` +- "What functions exist?" -> `functions-directory.md`, `functions-user.md`, `functions-time.md` + +## File Characteristics + +- Each file is under 300 lines +- Each file is self-contained (understandable without other files) +- Each file includes concrete examples with inline comments +- Examples show both correct usage and common pitfalls diff --git a/product/admin/rap/overview-intro.md b/product/admin/rap/overview-intro.md new file mode 100644 index 0000000..66ace71 --- /dev/null +++ b/product/admin/rap/overview-intro.md @@ -0,0 +1,58 @@ +# CEL Overview + +CEL (Common Expression Language) is Google's expression language for policy evaluation. ConductorOne uses CEL to let you encode access logic that would be impossible with dropdown menus. + +## Why CEL for Authorization + +These properties matter for access control: + +| Property | Why It Matters | +|----------|----------------| +| **Fast** | Evaluates in microseconds - access decisions are in the critical path | +| **Bounded execution** | No loops means expressions always terminate - authorization cannot hang | +| **No side effects** | Policy evaluation cannot modify state or permissions | +| **Type-safe** | Errors caught when you save, not at 2 AM during emergency access | + +## Where CEL is Used + +| Environment | Returns | Example Use | +|-------------|---------|-------------| +| Policy Conditions | `bool` | Route requests to different approval workflows | +| Dynamic Groups | `bool` | Automatically maintain group membership | +| Policy Steps | `User` or `list` | Dynamically select approvers | + +## Basic Syntax + +```cel +// Field access +subject.department == "Engineering" + +// Boolean operators +subject.department == "Engineering" && task.is_grant_permanent + +// Function calls +c1.directory.users.v1.GetManagers(subject) + +// Ternary conditional +size(managers) > 0 ? managers : appOwners +``` + +## What's C1's vs Google's + +| Layer | Google's CEL | ConductorOne's Extensions | +|-------|--------------|---------------------------| +| Syntax | All operators, macros (`has()`, `size()`) | Nothing added | +| Types | Primitives, lists, maps, timestamps | `User`, `Task`, `AppEntitlement` | +| Functions | String methods, math | `c1.directory.*`, `c1.user.*` | + +**Rule of thumb:** If it starts with `c1.`, it's ConductorOne's extension. + +## How Expressions are Evaluated + +```mermaid +flowchart LR + A[Expression String] -->|Compile once| B[cel.Program] + B -->|Evaluate with variables| C[Result] +``` + +Expressions are compiled when you save and cached. Evaluation happens against specific users/requests in microseconds. diff --git a/product/admin/rap/patterns-common.md b/product/admin/rap/patterns-common.md new file mode 100644 index 0000000..5266041 --- /dev/null +++ b/product/admin/rap/patterns-common.md @@ -0,0 +1,169 @@ +# Common CEL Patterns + +Patterns that work across all CEL environments. + +## Safe Field Access + +### Using has() + +```cel +// Check before accessing +has(subject.department) && subject.department == "Engineering" + +// For profile fields (always use has) +has(subject.profile.costCenter) && subject.profile["costCenter"] == "CC-123" +``` + +### Using "in" for Maps + +```cel +// Check if key exists in profile +"costCenter" in subject.profile && subject.profile["costCenter"] == "CC-123" + +// Required for keys with spaces +"Cost Center" in subject.profile && subject.profile["Cost Center"] == "R&D" +``` + +### Default Values + +```cel +// Using ternary +has(subject.department) ? subject.department : "Unknown" + +// Using ifEmpty (for empty strings, not missing fields) +subject.department.ifEmpty("Unknown") +``` + +--- + +## List Operations + +### Membership Check + +```cel +subject.department in ["Engineering", "Product", "Design"] +``` + +### Size Check + +```cel +size(appOwners) > 0 +``` + +### Exists (any match) + +```cel +appOwners.exists(owner, owner.department == "Security") +``` + +### Safe Index Access + +```cel +// BAD: crashes on empty list +appOwners[0] + +// GOOD: check first +size(appOwners) > 0 ? [appOwners[0]] : [] +``` + +--- + +## Ternary Conditional + +```cel +condition ? value_if_true : value_if_false +``` + +**Example:** +```cel +// Approver fallback +size(managers) > 0 ? managers : appOwners + +// Status mapping +item.active == true ? "enabled" : "disabled" +``` + +--- + +## Boolean Logic + +### AND (both must be true) + +```cel +subject.department == "Engineering" && subject.employment_type == "employee" +``` + +### OR (either can be true) + +```cel +subject.department == "Engineering" || subject.department == "Product" +``` + +### NOT + +```cel +!(subject.department in ["Contractors", "Vendors"]) +``` + +### Best Practice + +Keep conditions simple. Prefer multiple policy rules over complex compound expressions: + +```cel +// Hard to debug +(a && b) || (c && !d) || (e && f) + +// Better: use separate policy rules +``` + +--- + +## Enum Comparisons + +Both formats work: + +```cel +// SCREAMING_SNAKE format +subject.status == USER_STATUS_ENABLED +task.origin == TASK_ORIGIN_REQUEST + +// Namespaced format +subject.status == UserStatus.ENABLED +task.origin == TaskOrigin.WEBAPP +``` + +--- + +## Anti-Patterns + +### Deep Nesting + +```cel +// BAD: hard to debug +c1.directory.users.v1.GetManagers( + c1.directory.users.v1.GetManagers( + c1.directory.users.v1.GetManagers(subject)[0] + )[0] +) + +// BETTER: use multiple policy steps +``` + +### Hardcoded Users + +```cel +// BAD: user might leave +[c1.directory.users.v1.FindByEmail("john@company.com")] + +// BETTER: use entitlement-based groups +c1.directory.apps.v1.GetEntitlementMembers("app", "approvers") +``` + +### Complex Boolean Logic + +```cel +// BAD: impossible to debug +(a && b) || (c && !d) || (e && f && !g) + +// BETTER: multiple policy rules with simple conditions +``` diff --git a/product/admin/rap/terraform-examples.md b/product/admin/rap/terraform-examples.md new file mode 100644 index 0000000..6a34541 --- /dev/null +++ b/product/admin/rap/terraform-examples.md @@ -0,0 +1,160 @@ +# CEL in Terraform + +CEL expressions in the ConductorOne Terraform provider. + +## Resources Supporting CEL + +| Resource | Attribute | Purpose | +|----------|-----------|---------| +| `conductorone_policy` | `rules[].condition` | Route requests to policy steps | +| `conductorone_policy` | `expression_approval.expressions[]` | Dynamic approver selection | +| `conductorone_policy` | `wait_condition.condition` | Wait step conditions | +| `conductorone_access_review` | `cel_expression_scope.expression` | Filter users in scope | + +--- + +## Policy Rule Conditions + +Route requests to different approval workflows: + +```hcl +resource "conductorone_policy" "contractor_policy" { + display_name = "Contractor Access Policy" + + rules = [ + { + condition = "subject.employment_type == \"contractor\"" + policy_key = "contractor_approval" + }, + { + condition = "entitlement.app_resource_type == \"Production\"" + policy_key = "production_approval" + } + ] + + policy_steps = { + contractor_approval = { + steps = [ + # ... approval steps + ] + } + production_approval = { + steps = [ + # ... approval steps + ] + } + } +} +``` + +--- + +## Expression-Based Approvers + +Dynamically select who approves: + +```hcl +resource "conductorone_policy" "dynamic_approval" { + display_name = "Dynamic Approval Policy" + + policy_steps = { + default = { + steps = [ + { + approval = { + expression_approval = { + expressions = [ + "c1.directory.users.v1.GetManagers(subject)", + "appOwners" + ] + allow_self_approval = false + fallback = true + fallback_user_ids = ["fallback-admin-user-id"] + } + } + } + ] + } + } +} +``` + +First non-empty result is used. Enable `fallback` for safety. + +--- + +## Wait Conditions + +Pause until a condition is met: + +```hcl +resource "conductorone_policy" "wait_for_manager" { + display_name = "Policy with Wait Condition" + + policy_steps = { + default = { + steps = [ + { + wait = { + name = "Wait for manager assignment" + timeout_duration = "24h" + wait_condition = { + condition = "has(subject.manager_id)" + } + } + }, + { + approval = { + manager_approval = { + allow_self_approval = false + } + } + } + ] + } + } +} +``` + +--- + +## Access Review Scopes + +Filter which users are reviewed: + +```hcl +resource "conductorone_access_review" "engineering_review" { + display_name = "Engineering Quarterly Review" + + access_review_scope_v2 = { + cel_expression_scope = { + expression = "subject.department == \"Engineering\"" + } + } +} +``` + +--- + +## HCL Quoting + +CEL expressions in HCL require escaped quotes: + +```hcl +# Escaped quotes +condition = "subject.department == \"Engineering\"" + +# Heredoc for complex expressions (easier to read) +condition = <<-EOT + subject.department == "Engineering" && + subject.job_title.contains("Manager") +EOT +``` + +--- + +## Tips + +1. **Validate in UI first** - Test expressions in the ConductorOne UI before deploying via Terraform +2. **Use heredoc for readability** - Complex expressions are easier to read with heredoc syntax +3. **Check return types** - Conditions return `bool`, approvers return `User`/`list` diff --git a/product/admin/rap/types.md b/product/admin/rap/types.md new file mode 100644 index 0000000..ae00aaf --- /dev/null +++ b/product/admin/rap/types.md @@ -0,0 +1,221 @@ +# CEL Type Definitions + +This document covers all types used in ConductorOne CEL expressions: primitives, time types, collections, enums, and object types. + +## Primitive Types + +| Type | Description | Example values | +|:-----|:------------|:---------------| +| `string` | Text value | `"Engineering"`, `"user@company.com"` | +| `bool` | Boolean true/false | `true`, `false` | +| `int` | Integer number | `0`, `42`, `-1` | +| `double` | Floating-point number | `3.14`, `0.5` | + +## Time Types + +| Type | Description | How to create | +|:-----|:------------|:--------------| +| `timestamp` | A point in time (UTC) | `now()`, `timestamp("2025-01-01T00:00:00Z")`, `time.parse(...)` | +| `duration` | A length of time | `duration("24h")`, `duration("30m")`, `duration("720h")` | + +**Duration format:** Use `h` for hours, `m` for minutes, `s` for seconds. +- `"2h"` = 2 hours +- `"30m"` = 30 minutes +- `"720h"` = 30 days + +**Timestamp arithmetic:** +```go +now() + duration("24h") // 24 hours from now +now() - duration("720h") // 30 days ago +timestamp1 - timestamp2 // Returns a duration +``` + +## Collection Types + +| Type | Description | Example | +|:-----|:------------|:--------| +| `list` | Ordered list of items | `[user1, user2]`, `["a", "b", "c"]` | +| `map` | Key-value mapping | `subject.profile` (map of string to any) | + +**List operations:** +```go +size(myList) // Number of items +myList[0] // First item (0-indexed) +"value" in myList // Check membership +myList + otherList // Concatenate lists +myList.filter(x, x.status == UserStatus.ENABLED) // Filter +myList.map(x, x.email) // Transform to list of emails +myList.exists(x, x.department == "IT") // Any match? +myList.all(x, x.status == UserStatus.ENABLED) // All match? +``` + +## Enum Types + +Always use the full enum name (e.g., `UserStatus.ENABLED`, not just `ENABLED`). + +### UserStatus + +| Value | Meaning | +|:------|:--------| +| `UserStatus.ENABLED` | User is active | +| `UserStatus.DISABLED` | User is disabled | +| `UserStatus.DELETED` | User is deleted | + +### UserType + +| Value | Meaning | +|:------|:--------| +| `UserType.HUMAN` | Regular human user | +| `UserType.AGENT` | Automated agent | +| `UserType.SERVICE` | Service account | +| `UserType.SYSTEM` | System account | + +### TaskOrigin + +| Value | Meaning | +|:------|:--------| +| `TaskOrigin.WEBAPP` | Created in ConductorOne web interface | +| `TaskOrigin.SLACK` | Created via Slack integration | +| `TaskOrigin.API` | Created via API | +| `TaskOrigin.JIRA` | Created via Jira integration | +| `TaskOrigin.COPILOT` | Created via Copilot | +| `TaskOrigin.PROFILE_MEMBERSHIP_AUTOMATION` | Created by automation | +| `TaskOrigin.TIME_REVOKE` | Created by time-based revocation | + +### AppUserStatus (for triggers) + +Used in `ctx.trigger.oldAccount.status.status` and `ctx.trigger.newAccount.status.status`: + +| Value | Meaning | +|:------|:--------| +| `APP_USER_STATUS_ENABLED` | Account is active | +| `APP_USER_STATUS_DISABLED` | Account is disabled | +| `APP_USER_STATUS_DELETED` | Account is deleted | + +### TimeFormat + +| Constant | Format | Example output | +|:---------|:-------|:---------------| +| `TimeFormat.RFC3339` | ISO 8601 / RFC 3339 | `2025-10-22T14:30:00Z` | +| `TimeFormat.DATE` | Date only | `2025-10-22` | +| `TimeFormat.DATETIME` | Date and time | `2025-10-22 14:30:00` | +| `TimeFormat.TIME` | Time only | `14:30:00` | + +## Object Types + +### User vs AppUser + +These are different types: +- **User** = A person in the ConductorOne directory (synced from identity provider) +- **AppUser** = That person's account in a specific app (GitHub account, Okta account, etc.) + +One User can have many AppUsers across different connected applications. + +### User + +A person in the ConductorOne directory. + +**Returned by:** `FindByEmail`, `GetByID`, `GetManagers`, `DirectReports`, `GetEntitlementMembers` + +**Available as:** `subject`, elements of `appOwners`, `ctx.trigger.oldUser`, `ctx.trigger.newUser` + +| Field | Type | Description | +|:------|:-----|:------------| +| `id` | string | Unique user identifier | +| `email` | string | Primary email address | +| `emails` | list<string> | All email addresses | +| `displayName` | string | Display name | +| `username` | string | Username | +| `usernames` | list<string> | All usernames | +| `department` | string | Department | +| `jobTitle` | string | Job title | +| `employmentType` | string | Employment type (e.g., "Full Time") | +| `employmentStatus` | string | Employment status (e.g., "Active") | +| `status` | UserStatus | User status enum | +| `directoryStatus` | UserStatus | Directory sync status | +| `type` | UserType | User type enum | +| `manager` | string | Manager's email | +| `manager_id` | string | Manager's user ID | +| `profile` | map | Custom profile attributes | +| `attributes` | map | Custom user attributes | + +### AppUser + +A user's account within a specific connected application. + +**Returned by:** `ListAppUsersForUser` + +**Available as:** `ctx.trigger.oldAccount`, `ctx.trigger.newAccount` + +| Field | Type | Description | +|:------|:-----|:------------| +| `id` | string | App user identifier | +| `displayName` | string | Display name | +| `username` | string | Username in the app | +| `usernames` | list<string> | All usernames | +| `email` | string | Email in the app | +| `emails` | list<string> | All emails | +| `employeeIds` | list<string> | Employee IDs | +| `status` | AppUserStatus | Nested status object | +| `status.status` | enum | APP_USER_STATUS_ENABLED/DISABLED/DELETED | +| `status.details` | string | Status details | +| `profile` | map | Custom profile attributes | +| `attributes` | map | Attribute mappings | + +### Group + +A ConductorOne group (entitlement in the builtin Groups app). + +**Returned by:** `FindByName` + +| Field | Type | Description | +|:------|:-----|:------------| +| `id` | string | Group identifier | +| `app_id` | string | App ID the group belongs to | +| `display_name` | string | Group display name | + +### Task + +An access request or task. Available as `task` in policy expressions. + +| Field | Type | Description | +|:------|:-----|:------------| +| `id` | string | Unique task identifier | +| `numericId` | string | Numeric task identifier | +| `displayName` | string | Human-readable task name | +| `origin` | TaskOrigin | Where the task was created | +| `isGrantPermanent` | bool | Whether access is permanent | +| `grantDuration` | duration | How long access is granted | +| `subjectUserId` | string | ID of user who is subject of task | +| `requestorUserId` | string | ID of user who created task | +| `analysis` | TaskAnalysis | Task analysis data | + +### TaskAnalysis + +Analysis data attached to a task. Available as `task.analysis`. + +| Field | Type | Description | +|:------|:-----|:------------| +| `id` | string | Analysis identifier | +| `hasConflictViolations` | bool | Whether conflicts exist | +| `conflictViolations` | list<string> | List of conflict IDs | + +### Entitlement + +The entitlement being requested. Available as `entitlement` in policy expressions. + +| Field | Type | Description | +|:------|:-----|:------------| +| `id` | string | Entitlement identifier | +| `appId` | string | Application identifier | + +## Built-in Variables + +| Variable | Type | Available in | Description | +|:---------|:-----|:-------------|:------------| +| `subject` | User | Policies, Groups, Automations, Campaigns, Account provisioning | The current user being evaluated | +| `task` | Task | Policies only | The current access request | +| `entitlement` | Entitlement | Policies only | The entitlement being requested | +| `appOwners` | list<User> | Policy step approvers only | Owners of the application | +| `ctx` | Context | Automations only | Workflow context and trigger data | +| `ip` | IP | Policies, Automations | Requestor's IP address (when available) | diff --git a/rap/INDEX.md b/rap/INDEX.md new file mode 100644 index 0000000..7f5a20d --- /dev/null +++ b/rap/INDEX.md @@ -0,0 +1,153 @@ +# Baton Connector Documentation Index + +Documentation for building ConductorOne Baton connectors. Request relevant sections based on user's question. + +## How to Use + +1. Read user's question +2. Identify relevant sections (up to 3-4) +3. Request those files +4. Answer using retrieved content + +## Available Sections + +### Conceptual + +| Section | File | Covers | +|---------|------|--------| +| What connectors do | `concepts-overview.md` | Problem solved, sync vs provision, reconciliation loop | +| Resource model | `concepts-resources.md` | Resources, entitlements, grants, traits | +| Sync lifecycle | `concepts-sync.md` | Four stages, pagination | +| ID correlation | `concepts-ids.md` | RawId, external_id, matching across syncs | + +### Building Connectors + +| Section | File | Covers | +|---------|------|--------| +| Project setup | `build-setup.md` | Directory structure, go.mod, main.go | +| ResourceSyncer | `build-syncer.md` | ResourceType(), List(), Entitlements(), Grants() | +| Pagination | `build-pagination.md` | Token vs Bag, cursor vs offset, nested | + +### Provisioning + +| Section | File | Covers | +|---------|------|--------| +| Grant and Revoke | `provision-grant.md` | ResourceProvisionerV2, Grant/Revoke | + +### Recipes (Cookbook) + +| Section | File | Covers | +|---------|------|--------| +| Authentication | `recipes-auth.md` | API key, OAuth2, JWT, LDAP, basic auth | +| Error handling | `recipes-errors.md` | Retryable vs fatal, context cancellation | +| Resource modeling | `recipes-modeling.md` | Hierarchies, traits, entitlements, grants | +| Testing | `recipes-testing.md` | Configurable base URL, mock servers, unit tests | +| Caching | `recipes-caching.md` | sync.Map, LRU, daemon mode, anti-patterns | + +### Meta-Connectors + +| Section | File | Covers | +|---------|------|--------| +| baton-http | `meta-http.md` | REST API via YAML config | +| baton-sql | `meta-sql.md` | Database via YAML config | +| CEL expressions | `meta-cel.md` | Data transformation | + +### Operations + +| Section | File | Covers | +|---------|------|--------| +| Run modes | `ops-modes.md` | One-shot vs daemon | + +### Troubleshooting + +| Section | File | Covers | +|---------|------|--------| +| Debugging workflow | `debug-workflow.md` | Step-by-step process | +| Common errors | `debug-errors.md` | Pagination loops, auth, rate limits | + +### Reference + +| Section | File | Covers | +|---------|------|--------| +| SDK interfaces | `ref-sdk.md` | ConnectorBuilder, ResourceSyncer, Provisioner | +| Configuration | `ref-config.md` | Flags, env vars, field types | +| C1 API | `ref-c1api.md` | Task types, lifecycle, heartbeat | +| FAQ | `ref-faq.md` | Common questions | +| Glossary | `ref-glossary.md` | Term definitions | + +### Publishing & Community + +| Section | File | Covers | +|---------|------|--------| +| Publishing | `publish-submit.md` | Registry, versioning, signing | +| Community | `community.md` | Getting help, contributing, reporting | + +--- + +## Selection Guidelines + +**"How do I..."** +- Build a connector -> `build-setup.md`, `build-syncer.md` +- Add pagination -> `build-pagination.md` +- Support provisioning -> `provision-grant.md` +- Connect REST API -> `meta-http.md` +- Connect database -> `meta-sql.md` +- Authenticate -> `recipes-auth.md` +- Handle errors -> `recipes-errors.md` +- Model resources -> `recipes-modeling.md` +- Test my connector -> `recipes-testing.md` +- Cache data -> `recipes-caching.md` +- Debug a problem -> `debug-workflow.md`, `debug-errors.md` +- Run in production -> `ops-modes.md` +- Publish my connector -> `publish-submit.md` +- Contribute -> `community.md` + +**"What is..."** +- A connector -> `concepts-overview.md` +- An entitlement/grant -> `concepts-resources.md` +- The sync lifecycle -> `concepts-sync.md` +- RawId -> `concepts-ids.md` +- CEL -> `meta-cel.md` +- daemon mode -> `ops-modes.md` +- c1z file -> `ref-faq.md` + +**Code with errors** +- Error message -> `debug-errors.md` +- Interface issue -> `build-syncer.md`, `ref-sdk.md` +- Pagination issue -> `build-pagination.md` +- Auth failure -> `recipes-auth.md`, `debug-errors.md` +- Cache problems -> `recipes-caching.md` + +**Configuration** +- CLI flags -> `ref-config.md` +- Environment variables -> `ref-config.md` +- Config files -> `ref-config.md` + +**Architecture** +- SDK interfaces -> `ref-sdk.md` +- C1 communication -> `ref-c1api.md` + +--- + +## Usage Examples + +User: "How do I implement pagination?" +Retrieve: `build-pagination.md` + +User: "Pagination loop error" +Retrieve: `debug-errors.md`, `build-pagination.md` + +User: "Connect REST API without Go" +Retrieve: `meta-http.md`, `meta-cel.md` + +User: "OAuth2 authentication" +Retrieve: `recipes-auth.md` + +User: "Cache users between List and Grants" +Retrieve: `recipes-caching.md` + +User: "What SDK interfaces do I implement?" +Retrieve: `ref-sdk.md` + +User: "How does daemon mode work?" +Retrieve: `ops-modes.md`, `ref-c1api.md` diff --git a/rap/build-pagination.md b/rap/build-pagination.md new file mode 100644 index 0000000..a874ea3 --- /dev/null +++ b/rap/build-pagination.md @@ -0,0 +1,183 @@ +# build-pagination + +Token vs Bag, cursor vs offset, nested pagination patterns. + +--- + +## Two Pagination Strategies + +**Cursor-based**: API returns opaque token for next page +``` +GET /users?cursor=abc123 +Response: { users: [...], next_cursor: "def456" } +``` + +**Offset-based**: You track page number or offset +``` +GET /users?offset=100&limit=50 +Response: { users: [...], total: 500 } +``` + +## Simple Pagination with Token + +For flat lists where API returns a next-page token: + +```go +func (b *userBuilder) List( + ctx context.Context, + parentResourceID *v2.ResourceId, + token *pagination.Token, +) ([]*v2.Resource, string, annotations.Annotations, error) { + pageToken := "" + if token != nil && token.Token != "" { + pageToken = token.Token + } + + users, nextToken, err := b.client.ListUsers(ctx, pageToken, 100) + if err != nil { + return nil, "", nil, err + } + + // Convert users to resources... + + // Return nextToken directly - empty string means done + return resources, nextToken, nil, nil +} +``` + +## Offset-Based Pagination + +When API uses offset/limit instead of cursors: + +```go +func (b *userBuilder) List( + ctx context.Context, + parentResourceID *v2.ResourceId, + token *pagination.Token, +) ([]*v2.Resource, string, annotations.Annotations, error) { + offset := 0 + if token != nil && token.Token != "" { + var err error + offset, err = strconv.Atoi(token.Token) + if err != nil { + return nil, "", nil, err + } + } + + pageSize := 100 + users, total, err := b.client.ListUsers(ctx, offset, pageSize) + if err != nil { + return nil, "", nil, err + } + + // Convert users to resources... + + // Calculate next token + nextOffset := offset + len(users) + nextToken := "" + if nextOffset < total { + nextToken = strconv.Itoa(nextOffset) + } + + return resources, nextToken, nil, nil +} +``` + +## Nested Pagination with Bag + +When paginating children within parents (e.g., members within groups): + +```go +func (b *groupBuilder) Grants( + ctx context.Context, + resource *v2.Resource, + token *pagination.Token, +) ([]*v2.Grant, string, annotations.Annotations, error) { + bag := &pagination.Bag{} + if token != nil && token.Token != "" { + err := bag.Unmarshal(token.Token) + if err != nil { + return nil, "", nil, err + } + } + + // Get or initialize page state + pageState := bag.Current() + if pageState == nil { + pageState = &pagination.PageState{ + ResourceTypeID: "member", + ResourceID: resource.Id.Resource, + } + bag.Push(pageState) + } + + // Fetch members using page state token + members, nextCursor, err := b.client.GetGroupMembers( + ctx, + resource.Id.Resource, + pageState.Token, + ) + if err != nil { + return nil, "", nil, err + } + + // Convert to grants... + + // Update pagination state + if nextCursor != "" { + pageState.Token = nextCursor + } else { + bag.Pop() + } + + nextToken, err := bag.Marshal() + if err != nil { + return nil, "", nil, err + } + + return grants, nextToken, nil, nil +} +``` + +## Meta-Connector Pagination + +In YAML config, declare pagination strategy: + +```yaml +# Cursor-based +pagination: + strategy: "cursor" + primary_key: "id" + +# Offset-based +pagination: + strategy: "offset" + limit_param: "limit" + offset_param: "offset" + page_size: 100 +``` + +For baton-sql, use query placeholders: +```sql +SELECT * FROM users +WHERE id > ? +ORDER BY id ASC +LIMIT ? +``` + +## Critical Invariant + +**Your next token must progress.** The SDK detects loops where the same token is returned twice in a row and errors. + +```go +// WRONG - returns same token, causes infinite loop +if hasMore { + return resources, currentToken, nil, nil // Bug! +} + +// RIGHT - return new token or empty string +if hasMore { + return resources, newToken, nil, nil +} +return resources, "", nil, nil // Done +``` diff --git a/rap/build-setup.md b/rap/build-setup.md new file mode 100644 index 0000000..bb8e1af --- /dev/null +++ b/rap/build-setup.md @@ -0,0 +1,169 @@ +# build-setup + +Directory structure, go.mod, main.go, Makefile for a new connector. + +--- + +## Project Structure + +``` +baton-myservice/ + cmd/ + baton-myservice/ + main.go # Entry point + pkg/ + connector/ + connector.go # Connector interface implementation + users.go # User resource syncer + groups.go # Group resource syncer + client.go # API client wrapper + go.mod + go.sum + Makefile + .golangci.yaml +``` + +## go.mod + +```go +module github.com/conductorone/baton-myservice + +go 1.21 + +require ( + github.com/conductorone/baton-sdk v0.2.x + github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 +) +``` + +Check baton-sdk for the current minimum Go version. + +## main.go + +```go +package main + +import ( + "context" + "fmt" + "os" + + "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/conductorone/baton-myservice/pkg/connector" +) + +var version = "dev" + +func main() { + ctx := context.Background() + + cfg := &config.Config{ + Fields: []field.SchemaField{ + field.StringField("api-token", + field.WithRequired(true), + field.WithDescription("API token for authentication"), + ), + field.StringField("base-url", + field.WithDescription("API base URL"), + field.WithDefaultValue("https://api.myservice.com"), + ), + }, + } + + cb, err := connector.New(ctx, cfg) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + c, err := connectorbuilder.NewConnector(ctx, cb) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + connectorbuilder.Run(ctx, c, cfg) +} +``` + +## Connector Constructor + +```go +// pkg/connector/connector.go +package connector + +type Connector struct { + client *MyServiceClient +} + +func New(ctx context.Context, cfg *config.Config) (*Connector, error) { + token := cfg.GetString("api-token") + baseURL := cfg.GetString("base-url") + + client, err := NewClient(baseURL, token) + if err != nil { + return nil, err + } + + return &Connector{client: client}, nil +} + +func (c *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncer { + return []connectorbuilder.ResourceSyncer{ + newUserBuilder(c.client), + newGroupBuilder(c.client), + } +} + +func (c *Connector) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { + return &v2.ConnectorMetadata{ + DisplayName: "My Service", + Description: "Syncs users and groups from My Service", + }, nil +} + +func (c *Connector) Validate(ctx context.Context) (annotations.Annotations, error) { + // Test the connection + _, err := c.client.GetCurrentUser(ctx) + if err != nil { + return nil, fmt.Errorf("failed to validate credentials: %w", err) + } + return nil, nil +} +``` + +## Makefile + +```makefile +GOOS = $(shell go env GOOS) +GOARCH = $(shell go env GOARCH) +BUILD_DIR = dist + +.PHONY: build +build: + go build -o $(BUILD_DIR)/baton-myservice ./cmd/baton-myservice + +.PHONY: lint +lint: + golangci-lint run + +.PHONY: test +test: + go test -v ./... + +.PHONY: update-deps +update-deps: + go get -u ./... + go mod tidy +``` + +## Required Dependencies + +| Tool | Purpose | +|------|---------| +| Go | See go.mod for version | +| golangci-lint | Code quality | +| make | Build automation | diff --git a/rap/build-syncer.md b/rap/build-syncer.md new file mode 100644 index 0000000..d0f2526 --- /dev/null +++ b/rap/build-syncer.md @@ -0,0 +1,214 @@ +# build-syncer + +Implementing ResourceType(), List(), Entitlements(), Grants() methods. + +--- + +## The ResourceSyncer Interface + +```go +type ResourceSyncer interface { + ResourceType(ctx context.Context) *v2.ResourceType + List(ctx context.Context, parentResourceID *v2.ResourceId, token *pagination.Token) ( + []*v2.Resource, string, annotations.Annotations, error) + Entitlements(ctx context.Context, resource *v2.Resource, token *pagination.Token) ( + []*v2.Entitlement, string, annotations.Annotations, error) + Grants(ctx context.Context, resource *v2.Resource, token *pagination.Token) ( + []*v2.Grant, string, annotations.Annotations, error) +} +``` + +## Complete User Builder Example + +```go +package connector + +import ( + "context" + + 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" +) + +var userResourceType = &v2.ResourceType{ + Id: "user", + DisplayName: "User", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}, +} + +type userBuilder struct { + client *MyServiceClient +} + +func newUserBuilder(client *MyServiceClient) *userBuilder { + return &userBuilder{client: client} +} + +func (b *userBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return userResourceType +} + +func (b *userBuilder) List( + ctx context.Context, + parentResourceID *v2.ResourceId, + token *pagination.Token, +) ([]*v2.Resource, string, annotations.Annotations, error) { + // Parse page token + pageToken := "" + if token != nil && token.Token != "" { + pageToken = token.Token + } + + // Fetch from API + users, nextToken, err := b.client.ListUsers(ctx, pageToken, 100) + if err != nil { + return nil, "", nil, err + } + + // Convert to resources + var resources []*v2.Resource + for _, user := range users { + resource, err := rs.NewUserResource( + user.Name, + userResourceType, + user.ID, + []rs.UserTraitOption{ + rs.WithEmail(user.Email, true), + rs.WithStatus(v2.UserTrait_Status_STATUS_ENABLED), + rs.WithUserLogin(user.Username), + }, + ) + if err != nil { + return nil, "", nil, err + } + resources = append(resources, resource) + } + + return resources, nextToken, nil, nil +} + +func (b *userBuilder) Entitlements( + ctx context.Context, + resource *v2.Resource, + token *pagination.Token, +) ([]*v2.Entitlement, string, annotations.Annotations, error) { + // Users typically don't offer entitlements + return nil, "", nil, nil +} + +func (b *userBuilder) Grants( + ctx context.Context, + resource *v2.Resource, + token *pagination.Token, +) ([]*v2.Grant, string, annotations.Annotations, error) { + // Users typically don't have grants on them + return nil, "", nil, nil +} +``` + +## Group Builder (With Entitlements and Grants) + +```go +var groupResourceType = &v2.ResourceType{ + Id: "group", + DisplayName: "Group", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, +} + +type groupBuilder struct { + client *MyServiceClient +} + +func (b *groupBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return groupResourceType +} + +func (b *groupBuilder) List( + ctx context.Context, + parentResourceID *v2.ResourceId, + token *pagination.Token, +) ([]*v2.Resource, string, annotations.Annotations, error) { + groups, nextToken, err := b.client.ListGroups(ctx, token.Token, 100) + if err != nil { + return nil, "", nil, err + } + + var resources []*v2.Resource + for _, group := range groups { + resource, err := rs.NewGroupResource( + group.Name, + groupResourceType, + group.ID, + []rs.GroupTraitOption{}, + ) + if err != nil { + return nil, "", nil, err + } + resources = append(resources, resource) + } + + return resources, nextToken, nil, nil +} + +func (b *groupBuilder) Entitlements( + ctx context.Context, + resource *v2.Resource, + token *pagination.Token, +) ([]*v2.Entitlement, string, annotations.Annotations, error) { + // Groups offer membership + entitlement := &v2.Entitlement{ + Id: "member", + DisplayName: "Member", + Description: "Member of " + resource.DisplayName, + Resource: resource, + GrantableTo: []*v2.ResourceType{userResourceType}, + Purpose: v2.Entitlement_PURPOSE_VALUE_ASSIGNMENT, + Slug: "member", + } + + return []*v2.Entitlement{entitlement}, "", nil, nil +} + +func (b *groupBuilder) Grants( + ctx context.Context, + resource *v2.Resource, + token *pagination.Token, +) ([]*v2.Grant, string, annotations.Annotations, error) { + groupID := resource.Id.Resource + + members, nextToken, err := b.client.GetGroupMembers(ctx, groupID, token.Token) + if err != nil { + return nil, "", nil, err + } + + var grants []*v2.Grant + for _, member := range members { + grant := &v2.Grant{ + Entitlement: &v2.Entitlement{ + Id: "member", + Resource: resource, + }, + Principal: &v2.Resource{ + Id: &v2.ResourceId{ + ResourceType: "user", + Resource: member.UserID, + }, + }, + } + grants = append(grants, grant) + } + + return grants, nextToken, nil, nil +} +``` + +## Key Points + +- **ResourceType()**: Called once per sync to learn resource type metadata +- **List()**: May be called multiple times for pagination +- **Entitlements()**: Called once per resource instance +- **Grants()**: Called once per resource instance, may paginate + +Return empty string for nextToken when done paginating. diff --git a/rap/community.md b/rap/community.md new file mode 100644 index 0000000..a8fc887 --- /dev/null +++ b/rap/community.md @@ -0,0 +1,139 @@ +# community + +Getting help and contributing to the Baton connector ecosystem. + +--- + +## Getting help + +### GitHub Discussions + +| Repository | Use For | +|------------|---------| +| [baton-sdk](https://github.com/ConductorOne/baton-sdk/discussions) | SDK questions, general development | +| Specific connector repos | Issues with that connector | + +Before asking: +1. Search existing issues +2. Check documentation +3. Review Cookbook patterns + +### Good question format + +Include: +- What you're trying to accomplish +- What you've tried +- Error messages (full text) +- Relevant code +- Connector version + +Bad: "My connector doesn't work" + +Good: "Sync fails with 'unauthorized' when listing users. Using baton-okta v0.5.2, API token auth, read_users scope. Error: [full text]" + +--- + +## Support channels + +| Channel | Response | Use For | +|---------|----------|---------| +| GitHub Issues | Days | Bugs, features | +| GitHub Discussions | Days | Questions | +| ConductorOne Support | Hours | Production (customers) | + +--- + +## Reporting issues + +### Bug report template + +```markdown +**Connector:** baton-example v1.2.3 +**Environment:** macOS 14.0, Go 1.23 + +**Steps:** +1. Configure with API key +2. Run sync +3. Observe error + +**Expected:** Sync completes +**Actual:** Error: [paste] +``` + +### Feature requests + +Describe the problem, not just the solution: + +Good: "Need to sync custom Okta attributes for access reviews" + +Not: "Add custom attribute support" + +### Security issues + +DO NOT file publicly. + +Report to: security@conductorone.com + +--- + +## Contributing + +### Pre-PR checklist + +- [ ] Follows SDK patterns +- [ ] `make lint` passes +- [ ] `make test` passes +- [ ] README documents permissions +- [ ] No credentials in code + +### Flow + +``` +1. Fork repo +2. Create branch +3. Make changes +4. Run build/lint/test +5. Submit PR +6. Address feedback +7. Merge +``` + +--- + +## Maintainer guide + +### Responsibilities + +| Task | Frequency | +|------|-----------| +| Triage issues | Weekly | +| Review PRs | As submitted | +| Update deps | Monthly | +| Release versions | As needed | +| Security monitoring | Ongoing | + +### Release process + +```bash +git tag v1.2.3 +git push origin v1.2.3 +# GitHub Actions handles build and release +``` + +### Issue handling + +| Type | Response | +|------|----------| +| Bug with repro | Prioritize fix | +| Bug without repro | Request info | +| Feature request | Evaluate, label | +| Question | Point to docs | +| Security | Follow security process | + +--- + +## Code of conduct + +Follows Contributor Covenant. + +Report violations to: open-source@conductorone.com diff --git a/rap/concepts-ids.md b/rap/concepts-ids.md new file mode 100644 index 0000000..eb1d32e --- /dev/null +++ b/rap/concepts-ids.md @@ -0,0 +1,67 @@ +# concepts-ids + +RawId annotation, external_id, and how ConductorOne matches resources across syncs. + +--- + +## Why ID Correlation Matters + +ConductorOne needs to know if a resource in this sync is the same resource from a previous sync. This enables: +- Tracking changes over time +- Correlating resources across connectors +- Supporting pre-sync reservation patterns + +## The RawId Annotation + +When building a resource, include the external system's ID: + +```go +resource, _ := resourceBuilder.NewGroupResource( + group.Name, + groupResourceType, + group.Id, // Used internally by SDK + []resource.GroupTraitOption{}, +) +// Add external ID for correlation +resource.WithAnnotation(&v2.RawId{Id: group.Id}) +``` + +## ID Flow Through the System + +| Stage | Term | Purpose | +|-------|------|---------| +| Connector output | `RawId` annotation | External system's stable identifier | +| Sync storage | `external_id` | Same value on ConnectorResource records | +| Domain objects | `source_connector_ids` | Map of connector_id to external_id | +| Domain objects | `raw_baton_id` | Canonical external ID after merge | + +Flow: `RawId` (connector) -> `external_id` (sync) -> `source_connector_ids` (domain) + +## What Value to Use + +Use the external system's native, stable identifier: + +| System | Use | +|--------|-----| +| Okta | `app.Id` | +| AWS | ARN | +| GCP | Project ID | +| GitHub | Repository ID (numeric) | +| Database | Primary key | + +**Properties of good IDs:** +- Stable (doesn't change when resource is renamed) +- Unique within the system +- Native to the external system + +## Common Mistakes + +**Using display names:** Names change. `engineering-team` might become `platform-team`. + +**Using composite keys:** If you construct `org/repo`, changes to either part break correlation. + +**Omitting RawId:** Without it, ConductorOne can't correlate resources across syncs. + +## Pre-sync Reservation + +The `match_baton_id` field (in Terraform/API) allows creating ConductorOne objects before the connector discovers them. When the connector syncs, resources are matched by this ID. diff --git a/rap/concepts-overview.md b/rap/concepts-overview.md new file mode 100644 index 0000000..dfbaf71 --- /dev/null +++ b/rap/concepts-overview.md @@ -0,0 +1,74 @@ +# concepts-overview + +What connectors do, sync vs provision, the reconciliation loop. + +--- + +## What Problem Connectors Solve + +A **connector** answers: *who has access to what?* + +ConductorOne needs visibility into users, groups, roles, and permissions across all systems. Every system stores this differently - Okta has users and groups, AWS has IAM roles and policies, Salesforce has profiles and permission sets. + +A connector translates access data from any system into a common format. Once connected, you get unified visibility across your infrastructure. + +## What a Connector Does + +In Baton terms, a connector is a program that can: +- **List resources** (users, groups, roles, apps, projects) +- **Define entitlements** (permissions that can be granted) +- **Emit grants** (who currently has which entitlements) + +## Sync vs Provision + +**Sync** (read): Pull access data into ConductorOne +- Who exists? What groups? What roles? +- What permissions are available? +- Who has what access right now? + +**Provision** (write): Push access changes back +- **Grant**: Give someone approved access +- **Revoke**: Remove terminated access +- **Create Account**: JIT provisioning +- **Delete Resource**: Remove accounts entirely + +## The Reconciliation Loop + +Together, sync and provision create a reconciliation loop: + +1. ConductorOne sees what access exists (sync) +2. Compares to what access *should* exist (policy) +3. Corrects any drift (provision) + +Access controls become self-healing. + +## Identity Providers + +IdPs (Okta, Azure AD, Google Workspace) have a unique role - they're the **source of truth** for user identities. + +IdP connectors: +- Define canonical user identities +- Enable correlation of users across other systems +- Originate user lifecycle (join, move, leave) + +Connecting an IdP establishes the identity foundation other connectors build upon. + +## The Connector Binary + +When you build a connector, you produce a standalone binary (e.g., `baton-okta`): + +- Embeds the SDK at compile time +- Self-contained, no runtime dependencies +- Produces a `.c1z` file as output + +```bash +./baton-okta --domain example.okta.com --api-token $TOKEN +ls sync.c1z # Output file +``` + +## When to Build vs Reuse + +- **Use existing connector** when it meets your needs +- **Contribute upstream** when missing a capability you need +- **Build new** when target system is unsupported +- **Use meta-connectors** (baton-http, baton-sql) for quick REST/SQL integration diff --git a/rap/concepts-resources.md b/rap/concepts-resources.md new file mode 100644 index 0000000..98f32d4 --- /dev/null +++ b/rap/concepts-resources.md @@ -0,0 +1,100 @@ +# concepts-resources + +Resources, entitlements, grants, traits, and the access graph. + +--- + +## The Access Graph + +Your connector produces an access graph with three node types: + +```mermaid +flowchart LR + subgraph R["RESOURCES
Things that exist"] + R1[Users, Groups,
Roles, Apps] + end + subgraph E["ENTITLEMENTS
Permissions that can be assigned"] + E1[Admin, Read,
Member] + end + subgraph G["GRANTS
Who has what"] + G1[Alice has Admin
on Database X] + end + R --> E --> G +``` + +## Resources + +Resources are things that exist in the target system: +- Users (individual accounts) +- Groups (collections of users) +- Roles (permission bundles) +- Apps (applications or services) +- Custom types (projects, repositories, teams) + +Each resource has a **resource type** with optional **traits**. + +## Traits + +Traits tell ConductorOne how to interpret a resource: + +| Trait | Use For | +|-------|---------| +| `TRAIT_USER` | Individual accounts | +| `TRAIT_GROUP` | Collections of users | +| `TRAIT_ROLE` | Permission sets | +| `TRAIT_APP` | Applications or services | +| `TRAIT_SECRET` | Credentials or tokens | + +```go +var userResourceType = &v2.ResourceType{ + Id: "user", + DisplayName: "User", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}, +} +``` + +Marking a resource with `TRAIT_USER` enables: +- Correlation with users from other systems +- Display in user-centric views +- User-specific policies + +## Entitlements + +Entitlements define what can be granted. They attach to resources. + +```go +entitlement := &v2.Entitlement{ + Id: "member", + DisplayName: "Member", + Resource: groupResource, +} +``` + +One resource can offer multiple entitlements. A GitHub repository might offer: read, write, maintain, admin. + +## Grants + +Grants record who has what: + +```go +grant := &v2.Grant{ + Principal: aliceResource, // Who + Entitlement: memberEntitlement, // What permission on what resource +} +``` + +A grant connects a **principal** (usually a user) to an **entitlement**. + +## Hierarchical Resources + +Resources can have parent-child relationships: +- GitHub: organizations contain repositories +- AWS: accounts contain services +- GCP: projects contain resources + +Express hierarchy through `parentResourceID` in your `List()` method. The SDK calls `List()` with no parent first (top-level), then for each parent that has children. + +This enables: +- Access shown in context (this role on *this* project) +- Scoped access reviews (all access within one org unit) +- Inheritance patterns where they exist diff --git a/rap/concepts-sync.md b/rap/concepts-sync.md new file mode 100644 index 0000000..d6e7ac7 --- /dev/null +++ b/rap/concepts-sync.md @@ -0,0 +1,84 @@ +# concepts-sync + +The four sync stages, pagination, and how the SDK orchestrates calls. + +--- + +## The Four Stages + +The SDK orchestrates sync in four stages: + +```mermaid +flowchart LR + subgraph Stage1["Stage 1: ResourceType()"] + RT[SDK learns what
resource types exist] + end + subgraph Stage2["Stage 2: List()"] + L[SDK fetches all
instances of each type] + end + subgraph Stage3["Stage 3: Entitlements()"] + E[SDK asks each resource
what entitlements it offers] + end + subgraph Stage4["Stage 4: Grants()"] + G[SDK discovers who
has each entitlement] + end + Stage1 --> Stage2 --> Stage3 --> Stage4 +``` + +**Example outputs:** +- Stage 1: user, group, role +- Stage 2: 127 users, 23 groups, 15 roles +- Stage 3: group-A offers "member", role-X offers "assigned" +- Stage 4: alice has "member" on group-A + +## Inversion of Control + +The SDK processes ALL resource types together for each stage, not one type completely before the next. + +You define what to sync. The SDK decides when to call your methods. + +This keeps connector code focused on data transformation, not orchestration. + +## The ResourceSyncer Interface + +Four methods give you a working connector: + +```go +type ResourceSyncer interface { + ResourceType(ctx) (*v2.ResourceType, error) + List(ctx, parentResourceID, token) ([]*v2.Resource, string, annotations, error) + Entitlements(ctx, resource, token) ([]*v2.Entitlement, string, annotations, error) + Grants(ctx, resource, token) ([]*v2.Grant, string, annotations, error) +} +``` + +## Pagination + +Each method returns: +- A list of results +- A `nextPageToken` string (empty when done) +- Optional annotations + +The SDK handles pagination orchestration. You return items and the next token. + +**Key invariant:** Your next token must progress. The SDK detects and errors on "same token" loops. + +## The Sync Pipeline + +```mermaid +flowchart LR + ES[External
System] --> C[Connector
yours] + C --> F[.c1z
File] + F --> SS[Sync
Service] + SS --> DO[Domain
Objects] + SS -.-> U["Uplift"] +``` + +1. **Fetch** - Your connector calls external API +2. **Transform** - Create Resource/Entitlement/Grant objects +3. **Output** - SDK writes to .c1z file (gzip SQLite) +4. **Ingest** - ConductorOne reads the file +5. **Uplift** - Raw records become domain objects + +**You control:** Steps 1-3 +**ConductorOne controls:** Steps 4-5 diff --git a/rap/debug-errors.md b/rap/debug-errors.md new file mode 100644 index 0000000..cbf337b --- /dev/null +++ b/rap/debug-errors.md @@ -0,0 +1,123 @@ +# debug-errors + +Common errors and their solutions. + +--- + +## Pagination Loop Detected + +**Error:** `pagination loop detected: same token returned` + +**Cause:** Your List/Grants method returned the same next token twice. + +**Fix:** +```go +// Wrong +return resources, currentToken, nil, nil + +// Right - return NEW token or empty string +if hasMore { + return resources, newToken, nil, nil +} +return resources, "", nil, nil +``` + +## Authentication Failures + +**Error:** `401 Unauthorized` or `403 Forbidden` + +**Causes:** +- Token expired +- Token lacks required scopes +- Wrong API endpoint + +**Fix:** +1. Verify token is valid: `curl -H "Authorization: Bearer $TOKEN" $API_URL` +2. Check required scopes in target system docs +3. Regenerate token with correct permissions + +## Rate Limiting + +**Error:** `429 Too Many Requests` + +**Cause:** Hitting API rate limits. + +**Fix:** The SDK's uhttp client handles retries automatically. If still failing: +- Reduce page size +- Add delays between requests +- Request rate limit increase from API provider + +## Empty Sync Results + +**Symptom:** Connector runs successfully but `baton resources` shows nothing. + +**Causes:** +- Credentials lack read permissions +- API returns empty due to filters +- Wrong base URL + +**Fix:** +1. Test API directly with curl +2. Check if filters in code exclude everything +3. Verify base URL matches environment (prod vs staging) + +## Connection Refused + +**Error:** `connection refused` or `no such host` + +**Causes:** +- Wrong hostname +- Firewall blocking connection +- Service is down + +**Fix:** +1. Verify URL is correct +2. Test connectivity: `curl -v $API_URL` +3. Check firewall/VPN requirements + +## Certificate Errors + +**Error:** `x509: certificate signed by unknown authority` + +**Cause:** Self-signed or internal CA certificate. + +**Fix for testing:** +```go +// Add to client setup +client.WithInsecureSkipVerify(true) +``` + +**Production fix:** Add CA certificate to trust store. + +## JSON Parsing Errors + +**Error:** `json: cannot unmarshal...` + +**Cause:** API response doesn't match expected structure. + +**Fix:** +1. Log raw response: `--log-level debug` +2. Compare actual response to struct definition +3. Handle optional/nullable fields + +## Missing Entitlements + +**Symptom:** Resources exist but no entitlements appear. + +**Cause:** Entitlements() method returns empty. + +**Fix:** Verify Entitlements() returns at least one entitlement for resources that should offer permissions. + +## Grants Not Appearing + +**Symptom:** Entitlements exist but no grants. + +**Causes:** +- Grants() not implemented +- Principal resource type doesn't exist +- Wrong entitlement ID in grant + +**Fix:** +1. Verify Grants() is called (add logging) +2. Check principal resource type matches a synced type +3. Verify entitlement ID matches what Entitlements() returns diff --git a/rap/debug-workflow.md b/rap/debug-workflow.md new file mode 100644 index 0000000..1ed97e4 --- /dev/null +++ b/rap/debug-workflow.md @@ -0,0 +1,96 @@ +# debug-workflow + +Step-by-step process for debugging connector issues. + +--- + +## 1. Run Locally First + +```bash +./baton-myservice --api-token "$TOKEN" -f sync.c1z +``` + +Check exit code. Non-zero means error. + +## 2. Inspect the Output + +```bash +# What resources were synced? +baton resources -f sync.c1z + +# What grants exist? +baton grants -f sync.c1z + +# What entitlements are available? +baton entitlements -f sync.c1z + +# Stats overview +baton stats -f sync.c1z +``` + +## 3. Increase Log Verbosity + +```bash +./baton-myservice --api-token "$TOKEN" --log-level debug -f sync.c1z +``` + +Debug logs show: +- API requests and responses +- Pagination state +- Resource counts per type + +## 4. Check for Common Issues + +**Zero resources?** +- Credentials may lack read permissions +- API endpoint may be wrong +- Filter may be excluding everything + +**Missing grants?** +- Check Grants() method is implemented +- Verify entitlements exist on resources +- Check pagination isn't truncating results + +**Pagination loop?** +- Your next token isn't progressing +- See error: "pagination loop detected" + +## 5. Test Individual Endpoints + +If the connector calls multiple APIs, test each: + +```bash +# Test with curl first +curl -H "Authorization: Bearer $TOKEN" \ + https://api.example.com/v1/users | jq . +``` + +## 6. Review Resource Mapping + +Resources may exist but not match expected types: + +```bash +# List specific resource type +baton resources -f sync.c1z --resource-type user + +# Look for unexpected types +baton resources -f sync.c1z | sort | uniq -c +``` + +## 7. Validate Against Production + +Compare local sync to production: +- Same resource counts? +- Same grant patterns? +- Any missing resource types? + +## Debugging Checklist + +| Symptom | Check | +|---------|-------| +| No resources | Credentials, API endpoint, filters | +| No grants | Grants() implementation, entitlements exist | +| Pagination loop | Token progression logic | +| Missing users | User list endpoint, status filters | +| Wrong counts | Page size, pagination completeness | +| Auth failures | Token validity, required scopes | diff --git a/rap/meta-cel.md b/rap/meta-cel.md new file mode 100644 index 0000000..cee0c3f --- /dev/null +++ b/rap/meta-cel.md @@ -0,0 +1,128 @@ +# meta-cel + +Data transformation with Common Expression Language in meta-connectors. + +--- + +## What is CEL + +CEL (Common Expression Language) is used in baton-http and baton-sql for: +- Field mapping from API/SQL responses +- Conditional logic +- String transformation +- Type conversion + +Think of it as a safer alternative to embedding code in config. + +## Basic Syntax + +```yaml +map: + # Direct field access + id: ".id" + + # String concatenation + display_name: ".first_name + ' ' + .last_name" + + # Ternary conditional + status: ".is_active ? 'enabled' : 'disabled'" + + # Null handling + last_login: ".last_login != null ? string(.last_login) : ''" +``` + +## Available Functions + +| Function | Purpose | Example | +|----------|---------|---------| +| `lowercase()` | To lowercase | `lowercase(.email)` | +| `uppercase()` | To uppercase | `uppercase(.code)` | +| `titlecase()` | Title case | `titlecase(.name)` | +| `trim()` | Remove whitespace | `trim(.value)` | +| `match()` | Regex match | `match(.email, ".*@corp\\.com")` | +| `extract()` | Regex extract | `extract(.urn, "user-([0-9]+)")` | +| `replace()` | String replace | `replace(.name, {"old": "_", "new": "-"})` | +| `get()` | Get with default | `get(.optional, "default")` | +| `has()` | Check field exists | `has(input.employee_id)` | +| `parse_json()` | Parse JSON string | `parse_json(.metadata).type` | +| `json_path()` | Extract from JSON | `json_path(.data, "user.name")` | +| `string()` | Convert to string | `string(.numeric_id)` | + +## Context Variables + +| Variable | Available In | Description | +|----------|--------------|-------------| +| `.column` or `item` | List, Grants | Current row/item | +| `resource` | Grants, Provisioning | Current resource | +| `principal` | Provisioning | User being modified | +| `entitlement` | Provisioning | Entitlement being modified | +| `input` | Account creation | User-provided values | +| `password` | Account creation | Generated password | + +## Common Patterns + +**Account type from field:** +```yaml +account_type: ".type == 'employee' ? 'human' : 'service'" +``` + +**Email from multiple possible fields:** +```yaml +emails: + - "has(.primary_email) ? .primary_email : .email" +``` + +**Status normalization:** +```yaml +status: ".status == 'active' || .status == 'enabled' ? 'enabled' : 'disabled'" +``` + +**Skip system accounts in grants:** +```yaml +grants: + - query: "SELECT * FROM role_members WHERE role_id = ?" + map: + - skip_if: ".account_type == 'system'" + principal_id: ".user_id" + principal_type: "user" + entitlement_id: "member" +``` + +**Build display name:** +```yaml +display_name: "titlecase(.first_name) + ' ' + titlecase(.last_name)" +``` + +**Handle optional nested field:** +```yaml +department: "has(.profile) && has(.profile.department) ? .profile.department : 'Unknown'" +``` + +## Provisioning Variables + +```yaml +provisioning: + vars: + user_id: "principal.ID" # Principal's resource ID + group_id: "resource.ID" # Target resource ID + email: "principal.DisplayName" # Can access other fields + timestamp: "now()" # Current timestamp + grant: + queries: + - "INSERT INTO members VALUES (?, ?, ?)" +``` + +## Type Coercion + +CEL is type-aware. Convert explicitly when needed: + +```yaml +# Integer to string +id: "string(.numeric_id)" + +# String to bool (if needed) +active: ".status == 'active'" + +# Handle null +value: ".field != null ? .field : ''" +``` diff --git a/rap/meta-http.md b/rap/meta-http.md new file mode 100644 index 0000000..c3a5147 --- /dev/null +++ b/rap/meta-http.md @@ -0,0 +1,147 @@ +# meta-http + +REST API integration via YAML configuration, no Go code required. + +--- + +## When to Use + +- Target system has REST/HTTP endpoints +- Access model is straightforward +- You want ops teams to maintain the integration +- Quick integration matters more than flexibility + +## Basic Structure + +```yaml +version: "1" +app_name: "My SaaS App" +app_description: "Syncs users and roles" + +connect: + base_url: "https://api.example.com/v1" + auth: + type: "bearer" + token: "${API_KEY}" + +resource_types: + user: + name: "User" + list: + request: + url: "/users" + method: "GET" + response: + items_path: "data.users" + map: + id: ".id" + display_name: ".name" + traits: + user: + emails: + - ".email" +``` + +## Authentication Types + +```yaml +# Bearer token +auth: + type: "bearer" + token: "${API_KEY}" + +# API key in header +auth: + type: "api_key" + header: "X-API-Key" + key: "${API_KEY}" + +# Basic auth +auth: + type: "basic" + username: "${USERNAME}" + password: "${PASSWORD}" + +# OAuth2 client credentials +auth: + type: "oauth2_client_credentials" + client_id: "${CLIENT_ID}" + client_secret: "${CLIENT_SECRET}" + token_url: "https://auth.example.com/oauth/token" +``` + +## Request Configuration + +```yaml +list: + request: + url: "/users" + method: "GET" + headers: + Accept: "application/json" + query_params: + status: "active" + response: + items_path: "data.users" # JSONPath to array +``` + +## URL Templates + +Dynamic URLs using Go templates: + +```yaml +grants: + - request: + url: "tmpl:/groups/{{.resource.id}}/members" + method: "GET" +``` + +Available variables: +- `.resource` - Current resource +- `.principal` - User for provisioning +- `.item` - Current item in iteration + +## Pagination + +```yaml +pagination: + strategy: "offset" + limit_param: "per_page" + offset_param: "page" + page_size: 100 + total_path: "meta.total" # Optional +``` + +Or cursor-based: +```yaml +pagination: + strategy: "cursor" + cursor_param: "cursor" + cursor_path: "meta.next_cursor" +``` + +## Response Mapping + +```yaml +map: + id: ".id" + display_name: ".attributes.name" + traits: + user: + emails: + - ".attributes.email" + status: ".attributes.active ? 'enabled' : 'disabled'" +``` + +## Running + +```bash +# Validate first +baton-http --config-path ./config.yaml --validate-config-only + +# One-shot sync +baton-http --config-path ./config.yaml -f sync.c1z + +# Inspect results +baton resources -f sync.c1z +``` diff --git a/rap/meta-sql.md b/rap/meta-sql.md new file mode 100644 index 0000000..90f085d --- /dev/null +++ b/rap/meta-sql.md @@ -0,0 +1,157 @@ +# meta-sql + +SQL database integration via YAML configuration. + +--- + +## Supported Databases + +- PostgreSQL (`postgres`) +- MySQL (`mysql`) +- SQL Server (`sqlserver`) +- Oracle (`oracle`) +- SAP HANA (`hana`) +- SQLite (`sqlite`) + +## Connection + +```yaml +connect: + scheme: "postgres" + host: "${DB_HOST}" + port: "5432" + database: "${DB_NAME}" + user: "${DB_USER}" + password: "${DB_PASS}" + params: + sslmode: "require" +``` + +Alternative DSN format: +```yaml +connect: + dsn: "postgres://${DB_HOST}:5432/${DB_NAME}?sslmode=require" + user: "${DB_USER}" + password: "${DB_PASS}" +``` + +## Listing Resources + +```yaml +resource_types: + user: + name: "User" + list: + query: | + SELECT id, username, email, status, department + FROM users + WHERE active = true + AND id > ? + ORDER BY id ASC + LIMIT ? + pagination: + strategy: "cursor" + primary_key: "id" + map: + id: ".id" + display_name: ".username" + traits: + user: + emails: + - ".email" + status: ".status == 'active' ? 'enabled' : 'disabled'" +``` + +## Query Placeholders + +- `?` - Current pagination cursor +- `?` - Page size +- `?` - Offset for offset-based pagination +- `?` - Current resource ID in grants query +- `?` - Named variable from `vars` block + +## Grants Discovery + +```yaml +grants: + - query: | + SELECT user_id, group_id + FROM group_members + WHERE group_id = ? + map: + - principal_id: ".user_id" + principal_type: "user" + entitlement_id: "member" + skip_if: ".user_type == 'system'" # Optional filter +``` + +## Provisioning + +```yaml +static_entitlements: + - id: "member" + display_name: "'Member'" + purpose: "assignment" + grantable_to: + - "user" + provisioning: + vars: + user_id: "principal.ID" + group_id: "resource.ID" + grant: + queries: + - | + INSERT INTO group_members (user_id, group_id) + VALUES (?, ?) + ON CONFLICT DO NOTHING + revoke: + queries: + - | + DELETE FROM group_members + WHERE user_id = ? AND group_id = ? +``` + +## Account Creation + +```yaml +account_provisioning: + schema: + - name: "username" + type: "string" + required: true + - name: "email" + type: "string" + required: true + + credentials: + random_password: + min_length: 16 + max_length: 32 + preferred: true + + create: + vars: + username: "input.username" + email: "input.email" + password: "password" + queries: + - | + INSERT INTO users (username, email, password_hash) + VALUES (?, ?, crypt(?, gen_salt('bf'))) +``` + +## Running + +```bash +# Validate +baton-sql --config-path ./config.yaml --validate-config-only + +# Sync +baton-sql --config-path ./config.yaml -f sync.c1z + +# With provisioning +baton-sql --config-path ./config.yaml \ + --client-id "$C1_CLIENT_ID" \ + --client-secret "$C1_CLIENT_SECRET" \ + --provisioning +``` diff --git a/rap/ops-modes.md b/rap/ops-modes.md new file mode 100644 index 0000000..4b2160e --- /dev/null +++ b/rap/ops-modes.md @@ -0,0 +1,100 @@ +# ops-modes + +One-shot vs daemon vs hosted mode. + +--- + +## Three Run Modes + +| Mode | Trigger | Behavior | +|------|---------|----------| +| **One-shot** | No `--client-id` | Run once, produce .c1z file, exit | +| **Daemon** | `--client-id` provided | Connect to C1, poll for tasks, run continuously | +| **Hosted** | ConductorOne infrastructure | Managed by ConductorOne, no local deployment | + +## One-Shot Mode + +Run once and produce a sync file: + +```bash +./baton-myservice \ + --api-token "$API_TOKEN" \ + -f sync.c1z + +# Inspect results +baton resources -f sync.c1z +baton grants -f sync.c1z +``` + +Use for: +- Local development and testing +- CI/CD pipelines +- Manual audits +- Debugging + +## Daemon Mode + +Connect to ConductorOne and process tasks continuously: + +```bash +./baton-myservice \ + --api-token "$API_TOKEN" \ + --client-id "$C1_CLIENT_ID" \ + --client-secret "$C1_CLIENT_SECRET" +``` + +The connector: +1. Authenticates to ConductorOne +2. Polls for sync/provisioning tasks +3. Executes tasks and reports results +4. Repeats until stopped + +Use for: +- Production deployments +- Automated syncs +- Provisioning workflows + +## Hosted Mode + +ConductorOne runs the connector for you: +- No infrastructure to manage +- Automatic updates +- Credentials stored in ConductorOne + +Check if your connector is available as hosted in the ConductorOne console. + +## Provisioning Flag + +Enable provisioning operations (grant/revoke/create/delete): + +```bash +./baton-myservice \ + --api-token "$API_TOKEN" \ + --client-id "$C1_CLIENT_ID" \ + --client-secret "$C1_CLIENT_SECRET" \ + --provisioning +``` + +Without `--provisioning`, the connector only syncs (read-only). + +## Environment Variables + +All flags have environment variable equivalents: + +| Flag | Environment Variable | +|------|---------------------| +| `--api-token` | `BATON_API_TOKEN` | +| `--client-id` | `BATON_CLIENT_ID` | +| `--client-secret` | `BATON_CLIENT_SECRET` | +| `--provisioning` | `BATON_PROVISIONING` | +| `--file` | `BATON_FILE` | +| `--log-level` | `BATON_LOG_LEVEL` | + +## Log Levels + +```bash +--log-level debug # Verbose, for troubleshooting +--log-level info # Default +--log-level warn # Warnings and errors only +--log-level error # Errors only +``` diff --git a/rap/provision-grant.md b/rap/provision-grant.md new file mode 100644 index 0000000..8f2a774 --- /dev/null +++ b/rap/provision-grant.md @@ -0,0 +1,145 @@ +# provision-grant + +GrantProvisionerV2 and RevokeProvisioner interfaces for Grant and Revoke operations. + +--- + +## The Interfaces + +```go +// V2 interface (recommended for new connectors) +type GrantProvisionerV2 interface { + Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) +} + +type RevokeProvisioner interface { + Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) +} +``` + +## Implementation Pattern + +Add provisioning to your resource builder: + +```go +type groupBuilder struct { + client *MyServiceClient +} + +// Implement ResourceSyncer methods... + +func (b *groupBuilder) Grant( + ctx context.Context, + principal *v2.Resource, + entitlement *v2.Entitlement, +) ([]*v2.Grant, annotations.Annotations, error) { + groupID := entitlement.Resource.Id.Resource + userID := principal.Id.Resource + + err := b.client.AddGroupMember(ctx, groupID, userID) + if err != nil { + if isAlreadyExistsError(err) { + return nil, nil, nil // Idempotent success + } + return nil, nil, fmt.Errorf("failed to add member: %w", err) + } + + // Return created grant + grant := sdkGrant.NewGrant(entitlement.Resource, entitlement.Slug, principal.Id) + return []*v2.Grant{grant}, nil, nil +} + +func (b *groupBuilder) Revoke( + ctx context.Context, + grant *v2.Grant, +) (annotations.Annotations, error) { + groupID := grant.Entitlement.Resource.Id.Resource + userID := grant.Principal.Id.Resource + + err := b.client.RemoveGroupMember(ctx, groupID, userID) + if err != nil { + if isNotFoundError(err) { + return nil, nil // Already revoked + } + return nil, fmt.Errorf("failed to remove member: %w", err) + } + + return nil, nil +} +``` + +## Registering Provisioning + +In your connector's constructor: + +```go +func (c *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncer { + return []connectorbuilder.ResourceSyncer{ + newUserBuilder(c.client), + newGroupBuilder(c.client), // Implements ResourceProvisionerV2 + } +} +``` + +The SDK detects if your builder implements `ResourceProvisionerV2` and enables provisioning automatically. + +## Multiple Entitlements + +Handle different entitlement types in a single resource: + +```go +func (b *groupBuilder) Grant( + ctx context.Context, + principal *v2.Resource, + entitlement *v2.Entitlement, +) ([]*v2.Grant, annotations.Annotations, error) { + groupID := entitlement.Resource.Id.Resource + userID := principal.Id.Resource + + switch entitlement.Slug { + case "member": + err := b.client.AddMember(ctx, groupID, userID) + // ... + case "admin": + err := b.client.AddAdmin(ctx, groupID, userID) + // ... + default: + return nil, nil, fmt.Errorf("unknown entitlement: %s", entitlement.Slug) + } + + grant := sdkGrant.NewGrant(entitlement.Resource, entitlement.Slug, principal.Id) + return []*v2.Grant{grant}, nil, nil +} +``` + +## Idempotency + +Provisioning operations should be idempotent: + +```go +// Good: handles already-exists gracefully +err := b.client.AddGroupMember(ctx, groupID, userID) +if err != nil && !isAlreadyMemberError(err) { + return nil, err +} +return nil, nil + +// Good: handles not-found gracefully +err := b.client.RemoveGroupMember(ctx, groupID, userID) +if err != nil && !isNotFoundError(err) { + return nil, err +} +return nil, nil +``` + +## Running with Provisioning + +The connector must be started with `--provisioning` flag: + +```bash +./baton-myservice \ + --api-token "$TOKEN" \ + --client-id "$C1_CLIENT_ID" \ + --client-secret "$C1_CLIENT_SECRET" \ + --provisioning +``` diff --git a/rap/publish-submit.md b/rap/publish-submit.md new file mode 100644 index 0000000..832de8a --- /dev/null +++ b/rap/publish-submit.md @@ -0,0 +1,162 @@ +# publish-submit + +Publishing connectors to the connector registry. + +--- + +## Publishing flow + +```mermaid +flowchart LR + CC[1. Create Connector] --> R1[Registry] + CV[2. Create Version] --> R2[Registry] + UB[3. Upload Binaries] --> R3[Registry] + FV[4. Finalize Version] --> R4[Registry] --> CH[Connector Hub] +``` + +--- + +## Version states + +```mermaid +flowchart LR + P[PENDING] --> U[UPLOADING] --> V[VALIDATING] + V --> PUB[PUBLISHED] + V --> F[FAILED] + PUB --> Y[YANKED] +``` + +| State | Meaning | +|-------|---------| +| PENDING | Version created, awaiting uploads | +| UPLOADING | Assets being uploaded | +| VALIDATING | Validation in progress | +| PUBLISHED | Available for download | +| YANKED | Withdrawn (still visible) | +| FAILED | Validation failed | + +--- + +## Release manifest + +| Field | Purpose | +|-------|---------| +| org | Organization identifier | +| name | Connector name | +| version | Semantic version | +| description | Human-readable description | +| repository_url | Source code repo | +| license | License identifier | +| assets | Platform binaries | +| commit_sha | Git commit | + +--- + +## Asset structure + +| Field | Purpose | +|-------|---------| +| platform | Target (e.g., `linux-amd64`) | +| filename | Binary filename | +| size_bytes | File size | +| sha256 | Checksum | +| download_url | Download location | +| signature_url | Detached signature | + +--- + +## Supported platforms + +| Platform | Description | +|----------|-------------| +| darwin-amd64 | macOS Intel | +| darwin-arm64 | macOS Apple Silicon | +| linux-amd64 | Linux x86_64 | +| linux-arm64 | Linux ARM64 | +| windows-amd64 | Windows x86_64 | + +--- + +## Publishing paths + +### Contributing upstream (recommended) + +1. Fork existing connector repo +2. Add changes +3. Submit PR +4. Maintainers review and merge +5. New version published + +Benefits: community benefits, maintainers handle publishing. + +### Fork and maintain + +1. Fork repository +2. Publish under your org +3. Maintain independently + +Trade-off: Full control but ongoing maintenance burden. + +### Internal only + +1. Don't publish to public registry +2. Deploy in daemon mode on your infrastructure +3. Suitable for proprietary systems + +--- + +## Signing + +Connectors can be signed with: +- GPG signatures +- Cosign (sigstore) + +Signatures verified during download. + +--- + +## Pre-submission checklist + +Before publishing: + +- [ ] Connector syncs correctly +- [ ] Required permissions documented +- [ ] README complete +- [ ] No credentials in code +- [ ] License file present +- [ ] Tests pass +- [ ] Lint passes +- [ ] Builds for all platforms + +--- + +## Versioning + +Use semantic versioning: + +| Version | When | +|---------|------| +| Major (1.0 -> 2.0) | Breaking changes | +| Minor (1.0 -> 1.1) | New features, backward compatible | +| Patch (1.0.0 -> 1.0.1) | Bug fixes | + +--- + +## CI/CD + +Most connectors use GitHub Actions: + +```yaml +on: + push: + tags: + - 'v*' + +jobs: + release: + # Build all platforms + # Upload to registry + # Sign binaries +``` + +Tag push triggers release workflow. diff --git a/rap/recipes-auth.md b/rap/recipes-auth.md new file mode 100644 index 0000000..8ae0004 --- /dev/null +++ b/rap/recipes-auth.md @@ -0,0 +1,139 @@ +# recipes-auth + +Authentication patterns for connector API clients. + +--- + +## API key (Bearer token) + +```go +import "github.com/conductorone/baton-sdk/pkg/uhttp" + +func NewClient(ctx context.Context, apiKey string) (*Client, error) { + httpClient, err := uhttp.NewBaseHttpClient(ctx, + uhttp.WithBearerToken(apiKey)) + if err != nil { + return nil, err + } + return &Client{http: httpClient, baseURL: "https://api.example.com"}, nil +} +``` + +`WithBearerToken` sets `Authorization: Bearer `. The SDK handles retries and rate limiting. + +--- + +## OAuth2 client credentials + +```go +import "golang.org/x/oauth2/clientcredentials" + +func NewClient(ctx context.Context, clientID, clientSecret, tokenURL string) (*Client, error) { + config := &clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: tokenURL, + Scopes: []string{"read", "write"}, + } + httpClient := config.Client(ctx) // Auto-refreshes tokens + return &Client{http: httpClient}, nil +} +``` + +The `clientcredentials` package handles token refresh automatically. + +--- + +## JWT service account (Google-style) + +```go +import ( + "google.golang.org/api/option" + admin "google.golang.org/api/admin/directory/v1" +) + +func NewGoogleClient(ctx context.Context, credentialsJSON []byte, adminEmail string) (*admin.Service, error) { + config, err := google.JWTConfigFromJSON(credentialsJSON, + admin.AdminDirectoryUserReadonlyScope, + admin.AdminDirectoryGroupReadonlyScope, + ) + if err != nil { + return nil, fmt.Errorf("failed to parse credentials: %w", err) + } + config.Subject = adminEmail // Impersonate domain admin + return admin.NewService(ctx, option.WithHTTPClient(config.Client(ctx))) +} +``` + +Domain-wide delegation requires `Subject` to specify which user to impersonate. + +--- + +## LDAP bind + +```go +import "github.com/go-ldap/ldap/v3" + +func NewLDAPClient(ctx context.Context, serverURL, bindDN, bindPassword string) (*ldap.Conn, error) { + conn, err := ldap.DialURL(serverURL) // ldaps://dc.example.com:636 + if err != nil { + return nil, fmt.Errorf("failed to connect to LDAP: %w", err) + } + err = conn.Bind(bindDN, bindPassword) + if err != nil { + conn.Close() + return nil, fmt.Errorf("failed to bind: %w", err) + } + return conn, nil +} +``` + +LDAP requires binding before queries. Use `ldaps://` (port 636) for TLS. + +--- + +## Basic auth + +```go +func NewClient(ctx context.Context, username, password string) (*Client, error) { + httpClient, err := uhttp.NewBaseHttpClient(ctx, + uhttp.WithBasicAuth(username, password)) + if err != nil { + return nil, err + } + return &Client{http: httpClient}, nil +} +``` + +--- + +## Custom header auth + +Some APIs use non-standard headers like `X-API-Key`: + +```go +func NewClient(ctx context.Context, apiKey string) (*Client, error) { + httpClient, err := uhttp.NewBaseHttpClient(ctx) + if err != nil { + return nil, err + } + // Add custom header to all requests + httpClient.Transport = &headerTransport{ + base: httpClient.Transport, + header: "X-API-Key", + value: apiKey, + } + return &Client{http: httpClient}, nil +} + +type headerTransport struct { + base http.RoundTripper + header string + value string +} + +func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set(t.header, t.value) + return t.base.RoundTrip(req) +} +``` diff --git a/rap/recipes-caching.md b/rap/recipes-caching.md new file mode 100644 index 0000000..de7d444 --- /dev/null +++ b/rap/recipes-caching.md @@ -0,0 +1,192 @@ +# recipes-caching + +Caching patterns and anti-patterns for connectors. + +--- + +## When to cache + +| Scenario | Cache? | Reason | +|----------|--------|--------| +| Grants() needs user details from List() | Yes | Avoids N+1 API calls | +| Entitlements() needs role definitions | Yes | Role metadata is stable | +| List() needs parent context | Maybe | Often passed via parentID | +| Any data across sync runs | No | Stale data causes drift | + +Do NOT cache across sync runs. Connector restarts clear caches anyway. + +--- + +## Thread-safe caching with sync.Map + +```go +type Connector struct { + client *client.Client + userCache sync.Map // map[userID]User - struct field, not package-level +} + +// Populate during List() +func (u *userBuilder) List(ctx context.Context, _ *v2.ResourceId, + pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + + users, next, err := u.client.ListUsers(ctx, pToken.Token) + if err != nil { + return nil, "", nil, err + } + + var resources []*v2.Resource + for _, user := range users { + u.connector.userCache.Store(user.ID, user) // Cache for later + r, _ := resource.NewUserResource(user.Name, userResourceType, user.ID) + resources = append(resources, r) + } + return resources, next, nil, nil +} + +// Use during Grants() +func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, + pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { + + memberIDs, _ := g.client.GetGroupMemberIDs(ctx, resource.Id.Resource) + + var grants []*v2.Grant + for _, memberID := range memberIDs { + if cached, ok := g.connector.userCache.Load(memberID); ok { + user := cached.(User) + // Use cached user data + } + } + return grants, "", nil, nil +} +``` + +--- + +## ANTI-PATTERN: Package-level caches + +**Critical bug pattern.** Package-level `sync.Map` persists across syncs in daemon mode. + +```go +// WRONG - package-level cache persists across syncs +var userCache sync.Map + +func lookupUser(id string) (*User, bool) { + if cached, ok := userCache.Load(id); ok { + return cached.(*User), true + } + return nil, false +} +``` + +What goes wrong: +1. Sync 1 caches users A, B, C +2. User B deleted from target system +3. Sync 2 runs (same process in daemon mode) +4. Cache still has user B +5. Grants reference deleted user +6. Access reviews show phantom access + +**Correct pattern - struct-scoped:** + +```go +type Connector struct { + client *client.Client + userCache sync.Map // Fresh per connector instance +} + +func New(ctx context.Context, client *client.Client) *Connector { + return &Connector{ + client: client, + userCache: sync.Map{}, // Fresh cache + } +} +``` + +Find violations: +```bash +grep -r "^var.*sync\.Map" pkg/ +``` + +--- + +## Memory-bounded caching + +For large datasets, use LRU cache: + +```go +import "github.com/hashicorp/golang-lru/v2" + +type Connector struct { + userCache *lru.Cache[string, User] +} + +func New(ctx context.Context) (*Connector, error) { + cache, err := lru.New[string, User](10000) // Max 10k entries + if err != nil { + return nil, err + } + return &Connector{userCache: cache}, nil +} +``` + +Alternative for very large datasets - batch lookups instead of caching: + +```go +func (c *Connector) lookupUsers(ctx context.Context, ids []string) (map[string]User, error) { + return c.client.GetUsersByIDs(ctx, ids) // Single batch API call +} +``` + +--- + +## Cache lifetime in daemon mode + +| Mode | Cache Lifetime | Risk | +|------|----------------|------| +| One-shot (CLI) | Process lifetime | Low - exits after sync | +| Daemon mode | Must be managed | High - stale data persists | + +Clear caches at sync boundaries: + +```go +type Connector struct { + client *client.Client + userCache sync.Map +} + +func (c *Connector) PrepareForSync(ctx context.Context) error { + c.userCache = sync.Map{} // Clear from previous sync + return nil +} +``` + +Time-based invalidation for long syncs: + +```go +type Connector struct { + userCache sync.Map + cachePopulatedAt time.Time +} + +func (c *Connector) getCachedUser(id string) (*User, bool) { + if time.Since(c.cachePopulatedAt) > 5*time.Minute { + c.userCache = sync.Map{} + c.cachePopulatedAt = time.Now() + } + if cached, ok := c.userCache.Load(id); ok { + return cached.(*User), true + } + return nil, false +} +``` + +--- + +## Cache expectations by mode + +| Scenario | Expected Behavior | +|----------|-------------------| +| CLI one-shot | Cache lives for single sync, process exits | +| Daemon between syncs | Cache cleared before each sync | +| Daemon during sync | Cache valid for sync duration | +| Long sync (>5 min) | Consider time-based invalidation | diff --git a/rap/recipes-errors.md b/rap/recipes-errors.md new file mode 100644 index 0000000..cf000ae --- /dev/null +++ b/rap/recipes-errors.md @@ -0,0 +1,158 @@ +# recipes-errors + +Error handling patterns for connectors. + +--- + +## Distinguish retryable from fatal errors + +```go +func (u *userBuilder) List(ctx context.Context, _ *v2.ResourceId, + pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + + users, err := u.client.ListUsers(ctx) + if err != nil { + if isRateLimitError(err) || isNetworkError(err) { + return nil, "", nil, err // SDK retries automatically + } + if isAuthError(err) { + return nil, "", nil, fmt.Errorf("baton-example: authentication failed (check credentials): %w", err) + } + return nil, "", nil, err + } + // ... +} + +func isRateLimitError(err error) bool { + var httpErr *HTTPError + if errors.As(err, &httpErr) { + return httpErr.StatusCode == 429 + } + return false +} +``` + +SDK handles retries for transient errors. Fatal errors need clear messages. + +--- + +## Context cancellation in loops + +```go +func (u *userBuilder) List(ctx context.Context, _ *v2.ResourceId, + pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + + users, err := u.client.ListUsers(ctx) + if err != nil { + return nil, "", nil, err + } + + var resources []*v2.Resource + for _, user := range users { + select { + case <-ctx.Done(): + return nil, "", nil, ctx.Err() + default: + } + r, err := resource.NewUserResource(user.Name, userResourceType, user.ID) + if err != nil { + return nil, "", nil, err + } + resources = append(resources, r) + } + return resources, "", nil, nil +} +``` + +Check `ctx.Done()` in loops processing many items. Cancelled context means stop immediately. + +--- + +## Error prefix convention + +Prefix errors with connector name for debugging: + +```go +return nil, "", nil, fmt.Errorf("baton-example: failed to list users: %w", err) +``` + +Pattern: `baton-: : %w` + +--- + +## Wrapping vs returning errors + +**Wrap** when adding context: +```go +if err != nil { + return fmt.Errorf("failed to parse user %s: %w", userID, err) +} +``` + +**Return directly** when no additional context helps: +```go +if err != nil { + return err +} +``` + +--- + +## HTTP status code handling + +| Status | Meaning | Action | +|--------|---------|--------| +| 400 | Bad request | Fatal - log request details | +| 401 | Unauthorized | Fatal - check credentials | +| 403 | Forbidden | Fatal - check permissions | +| 404 | Not found | Usually skip, not error | +| 429 | Rate limited | Retry (SDK handles) | +| 500+ | Server error | Retry (SDK handles) | + +```go +func handleResponse(resp *http.Response) error { + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated: + return nil + case http.StatusNotFound: + return nil // Resource doesn't exist, not an error + case http.StatusUnauthorized: + return fmt.Errorf("baton-example: unauthorized (check API key)") + case http.StatusForbidden: + return fmt.Errorf("baton-example: forbidden (check permissions)") + default: + if resp.StatusCode >= 500 { + return fmt.Errorf("server error: %d", resp.StatusCode) // Will retry + } + return fmt.Errorf("unexpected status: %d", resp.StatusCode) + } +} +``` + +--- + +## Partial success handling + +When processing multiple items, decide strategy upfront: + +**Fail fast** (default for sync): +```go +for _, item := range items { + if err := process(item); err != nil { + return err // Stop on first error + } +} +``` + +**Collect errors** (for provisioning): +```go +var errs []error +for _, item := range items { + if err := process(item); err != nil { + errs = append(errs, fmt.Errorf("item %s: %w", item.ID, err)) + } +} +if len(errs) > 0 { + return errors.Join(errs...) +} +``` diff --git a/rap/recipes-modeling.md b/rap/recipes-modeling.md new file mode 100644 index 0000000..432050e --- /dev/null +++ b/rap/recipes-modeling.md @@ -0,0 +1,184 @@ +# recipes-modeling + +Resource modeling patterns for connectors. + +--- + +## Parent-child hierarchies + +Model resources within other resources (repos in orgs, projects in accounts): + +```go +// Parent type declares children +var orgResourceType = &v2.ResourceType{ + Id: "organization", + DisplayName: "Organization", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, +} + +var projectResourceType = &v2.ResourceType{ + Id: "project", + DisplayName: "Project", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, +} + +// Org List() declares child relationship +func (o *orgBuilder) List(ctx context.Context, _ *v2.ResourceId, + pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + + orgs, _ := o.client.ListOrgs(ctx) + var resources []*v2.Resource + for _, org := range orgs { + r, _ := resource.NewResource(org.Name, orgResourceType, org.ID, + resource.WithAnnotation(&v2.ChildResourceType{ + ResourceTypeId: projectResourceType.Id, + }), + ) + resources = append(resources, r) + } + return resources, "", nil, nil +} + +// Project List() receives parent ID +func (p *projectBuilder) List(ctx context.Context, parentID *v2.ResourceId, + pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + + if parentID == nil { + return nil, "", nil, nil // Projects only exist within orgs + } + projects, _ := p.client.ListProjects(ctx, parentID.Resource) + var resources []*v2.Resource + for _, proj := range projects { + r, _ := resource.NewResource(proj.Name, projectResourceType, proj.ID, + resource.WithParentResourceID(parentID), + ) + resources = append(resources, r) + } + return resources, "", nil, nil +} +``` + +SDK calls child `List()` once per parent, passing `parentID`. + +--- + +## Deep hierarchies (3+ levels) + +AWS example: Account -> Region -> Resource + +```go +// Each level declares its children +accountType // WithAnnotation(&v2.ChildResourceType{ResourceTypeId: "region"}) +regionType // WithAnnotation(&v2.ChildResourceType{ResourceTypeId: "ec2_instance"}) +instanceType // Leaf node, no children +``` + +SDK traverses depth-first. Each level receives parent context. + +--- + +## User traits + +```go +r, _ := resource.NewUserResource( + user.DisplayName, + userResourceType, + user.ID, + resource.WithEmail(user.Email, true), // true = primary + resource.WithUserLogin(user.Username), + resource.WithStatus(v2.UserTrait_Status_STATUS_ENABLED), + resource.WithAccountType(v2.UserTrait_ACCOUNT_TYPE_HUMAN), +) +``` + +Common traits: +- `WithEmail(email, isPrimary)` - User's email +- `WithUserLogin(login)` - Username/login ID +- `WithStatus(status)` - ENABLED, DISABLED, DELETED +- `WithAccountType(type)` - HUMAN, SERVICE, SYSTEM + +--- + +## Group traits + +```go +r, _ := resource.NewGroupResource( + group.Name, + groupResourceType, + group.ID, + resource.WithGroupProfile(group.Description), +) +``` + +--- + +## Role traits + +```go +r, _ := resource.NewRoleResource( + role.Name, + roleResourceType, + role.ID, + resource.WithRoleProfile(role.Description), +) +``` + +--- + +## Custom resource types + +For resources that aren't users, groups, or roles: + +```go +var repositoryResourceType = &v2.ResourceType{ + Id: "repository", + DisplayName: "Repository", + Traits: []v2.ResourceType_Trait{}, // No standard trait +} + +r, _ := resource.NewResource( + repo.Name, + repositoryResourceType, + repo.ID, + // Add custom annotations as needed +) +``` + +--- + +## Entitlement patterns + +**Permission entitlements** (on a single resource): +```go +entitlement.NewPermissionEntitlement( + resource, // The resource this permission applies to + "admin", // Permission slug + entitlement.WithDisplayName("Administrator"), + entitlement.WithDescription("Full administrative access"), + entitlement.WithGrantableTo(userResourceType), +) +``` + +**Membership entitlements** (belonging to a group): +```go +entitlement.NewAssignmentEntitlement( + groupResource, + "member", + entitlement.WithDisplayName("Member"), + entitlement.WithGrantableTo(userResourceType), +) +``` + +--- + +## Grant patterns + +```go +grant.NewGrant( + resource, // Resource the entitlement belongs to + "admin", // Entitlement slug + principalID, // Who has the grant (usually user resource ID) +) +``` + +Principal is typically a `*v2.ResourceId` from a user resource. diff --git a/rap/recipes-testing.md b/rap/recipes-testing.md new file mode 100644 index 0000000..a46fd0c --- /dev/null +++ b/rap/recipes-testing.md @@ -0,0 +1,215 @@ +# recipes-testing + +Testing patterns for connectors. + +--- + +## Configurable base URL (required) + +Every connector must support configurable base URL for testing: + +```go +// cmd/baton-example/main.go +fields := []field.SchemaField{ + field.StringField("api-key", + field.WithRequired(true), + field.WithDescription("API key for authentication"), + ), + field.StringField("base-url", + field.WithDefaultValue("https://api.example.com"), + field.WithDescription("Base URL (use http://localhost:8080 for testing)"), + ), +} + +// pkg/client/client.go +func New(cfg *Config) *Client { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://api.example.com" + } + return &Client{baseURL: baseURL} +} +``` + +Usage: +```bash +./baton-example --api-key $KEY # Production +./baton-example --api-key test --base-url http://localhost:8080 # Testing +``` + +Without configurable base URL, you cannot test without hitting production. + +--- + +## Insecure TLS for mock servers + +For mock servers with self-signed certificates: + +```go +type Config struct { + APIKey string `mapstructure:"api-key"` + BaseURL string `mapstructure:"base-url"` + Insecure bool `mapstructure:"insecure"` +} + +func New(ctx context.Context, cfg *Config) (*Client, error) { + opts := []uhttp.Option{} + if cfg.Insecure { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + opts = append(opts, uhttp.WithTransport(transport)) + } + httpClient, err := uhttp.NewBaseHttpClient(ctx, opts...) + if err != nil { + return nil, err + } + return &Client{http: httpClient, baseURL: cfg.BaseURL}, nil +} +``` + +Usage: +```bash +./baton-example --api-key test --base-url https://localhost:8443 --insecure +``` + +--- + +## Unit testing resource builders + +```go +func TestUserBuilder_List(t *testing.T) { + // Mock client + mockClient := &MockClient{ + users: []User{ + {ID: "u1", Name: "Alice", Email: "alice@example.com"}, + {ID: "u2", Name: "Bob", Email: "bob@example.com"}, + }, + } + + builder := &userBuilder{client: mockClient} + resources, nextToken, _, err := builder.List(context.Background(), nil, &pagination.Token{}) + + require.NoError(t, err) + require.Len(t, resources, 2) + require.Empty(t, nextToken) + + assert.Equal(t, "u1", resources[0].Id.Resource) + assert.Equal(t, "Alice", resources[0].DisplayName) +} +``` + +--- + +## Integration testing with mock server + +```go +func TestConnector_Integration(t *testing.T) { + // Start mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/users": + json.NewEncoder(w).Encode(map[string]any{ + "users": []map[string]string{ + {"id": "u1", "name": "Alice"}, + }, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + // Create connector pointing to mock + cfg := &Config{ + APIKey: "test-key", + BaseURL: server.URL, + } + connector, err := NewConnector(context.Background(), cfg) + require.NoError(t, err) + + // Test sync + // ... +} +``` + +--- + +## Testing pagination + +```go +func TestUserBuilder_Pagination(t *testing.T) { + mockClient := &MockClient{ + pages: map[string][]User{ + "": {{ID: "u1"}, {ID: "u2"}}, // First page + "page2": {{ID: "u3"}, {ID: "u4"}}, // Second page + "page3": {{ID: "u5"}}, // Last page + }, + nextTokens: map[string]string{ + "": "page2", + "page2": "page3", + "page3": "", // Empty = no more pages + }, + } + + builder := &userBuilder{client: mockClient} + + // First page + r1, next1, _, _ := builder.List(ctx, nil, &pagination.Token{Token: ""}) + assert.Len(t, r1, 2) + assert.Equal(t, "page2", next1) + + // Second page + r2, next2, _, _ := builder.List(ctx, nil, &pagination.Token{Token: "page2"}) + assert.Len(t, r2, 2) + assert.Equal(t, "page3", next2) + + // Last page + r3, next3, _, _ := builder.List(ctx, nil, &pagination.Token{Token: "page3"}) + assert.Len(t, r3, 1) + assert.Empty(t, next3) +} +``` + +--- + +## Testing grants + +```go +func TestGroupBuilder_Grants(t *testing.T) { + mockClient := &MockClient{ + groupMembers: map[string][]string{ + "g1": {"u1", "u2"}, + }, + } + + builder := &groupBuilder{client: mockClient} + groupResource := makeGroupResource("g1", "Admins") + + grants, _, _, err := builder.Grants(ctx, groupResource, &pagination.Token{}) + + require.NoError(t, err) + require.Len(t, grants, 2) + assert.Equal(t, "u1", grants[0].Principal.Id.Resource) + assert.Equal(t, "u2", grants[1].Principal.Id.Resource) +} +``` + +--- + +## Makefile test targets + +```makefile +.PHONY: test +test: + go test -v ./... + +.PHONY: test-integration +test-integration: + go test -v -tags=integration ./... + +.PHONY: test-coverage +test-coverage: + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out +``` diff --git a/rap/ref-c1api.md b/rap/ref-c1api.md new file mode 100644 index 0000000..4fdf64e --- /dev/null +++ b/rap/ref-c1api.md @@ -0,0 +1,184 @@ +# ref-c1api + +How connectors communicate with ConductorOne platform. SDK handles this; understanding helps debugging. + +--- + +## Architecture + +``` +ConductorOne Platform Connector (Daemon Mode) ++---------------------+ +------------------------+ +| Task Queue |<--- gRPC ---------| BatonServiceClient | +| (Sync, Grant, etc) |--- Task --------->| Task Handler | +| Upload Service |<--- c1z upload ---| ConnectorBuilderV2 | +| Heartbeat |<--- keepalive ----| HeartbeatLoop | ++---------------------+ +------------------------+ +``` + +- Connectors poll for tasks (pull model) +- Results upload via streaming gRPC (512KB chunks) +- Heartbeats keep tasks alive during long operations + +--- + +## BatonServiceClient interface + +```go +type BatonServiceClient interface { + Hello(ctx, req) (*HelloResponse, error) // Initial handshake + GetTask(ctx, req) (*GetTaskResponse, error) // Poll for work + Heartbeat(ctx, req) (*HeartbeatResponse, error) // Keep task alive + FinishTask(ctx, req) (*FinishTaskResponse, error) // Report completion + Upload(ctx, task, reader) error // Upload c1z file +} +``` + +--- + +## Task types + +### SyncTask + +```go +type SyncTask struct { + SyncId string // Unique identifier + ResourceTypes []string // Types to sync (empty = all) + ResourceIds []string // Specific resources (targeted) + SkipEntitlements bool + SkipGrants bool +} +``` + +Produces: c1z file uploaded to C1 + +### GrantTask + +```go +type GrantTask struct { + Entitlement *v2.Entitlement // What to grant + Principal *v2.Resource // Who receives it +} +``` + +Calls: Your `Grant()` implementation + +### RevokeTask + +```go +type RevokeTask struct { + Grant *v2.Grant // Grant to revoke +} +``` + +Calls: Your `Revoke()` implementation + +### CreateAccountTask + +```go +type CreateAccountTask struct { + AccountInfo *v2.AccountInfo + CredentialOptions *v2.LocalCredentialOptions +} +``` + +Calls: Your `CreateAccount()` implementation + +### DeleteResourceTask + +```go +type DeleteResourceTask struct { + ResourceId *v2.ResourceId + ParentResourceId *v2.ResourceId +} +``` + +Calls: Your `Delete()` implementation + +### RotateCredentialTask + +```go +type RotateCredentialTask struct { + ResourceId *v2.ResourceId + CredentialOptions *v2.LocalCredentialOptions +} +``` + +Calls: Your `Rotate()` implementation + +--- + +## Task lifecycle + +``` +Connector C1 Platform + |--- Hello (identify) -------------->| + |<-- HelloResponse (config) ---------| + | | + |--- GetTask (poll) ---------------->| + |<-- Task or NoTask -----------------| + | | + [If task] | + |--- Heartbeat (every 30s) --------->| + | | + [Process task] | + | | + |--- FinishTask (result) ----------->| +``` + +--- + +## Heartbeat + +Tasks have 60-second default timeout. Connector sends heartbeats every 30 seconds during processing. + +```go +// SDK handles automatically +go func() { + ticker := time.NewTicker(30 * time.Second) + for range ticker.C { + client.Heartbeat(ctx, &HeartbeatRequest{TaskId: task.Id}) + } +}() +``` + +Missing heartbeats = task considered failed, may be reassigned. + +--- + +## c1z upload + +Sync results upload via streaming: + +```go +// SDK handles chunking (512KB) +err := client.Upload(ctx, task, c1zFileReader) +``` + +Large syncs may take several minutes to upload. Heartbeats continue during upload. + +--- + +## Authentication + +Daemon mode uses OAuth2 client credentials: + +```bash +./baton-example --client-id $CLIENT_ID --client-secret $CLIENT_SECRET +``` + +SDK exchanges credentials for access token, refreshes automatically. + +--- + +## Error handling + +| Error | SDK Behavior | +|-------|--------------| +| Network timeout | Retry with backoff | +| 401 Unauthorized | Refresh token, retry | +| 429 Rate limited | Backoff, retry | +| Task timeout | Task marked failed | +| Upload failure | Retry upload | + +Connector code should return errors; SDK handles retry logic. diff --git a/rap/ref-config.md b/rap/ref-config.md new file mode 100644 index 0000000..6c6aa83 --- /dev/null +++ b/rap/ref-config.md @@ -0,0 +1,202 @@ +# ref-config + +Configuration system reference for baton connectors. + +--- + +## Precedence + +1. CLI flags (`--domain example.com`) +2. Environment variables (`BATON_DOMAIN=example.com`) +3. Config files (YAML) + +CLI wins over env, env wins over file. + +--- + +## Standard flags (all connectors) + +### Output & logging + +| Flag | Default | Description | +|------|---------|-------------| +| `--file`, `-f` | `sync.c1z` | Output file path | +| `--log-level` | `info` | `debug`, `info`, `warn`, `error` | +| `--log-format` | auto | `json` or `console` | + +### Daemon mode + +| Flag | Description | +|------|-------------| +| `--client-id` | C1 OAuth client ID (enables daemon mode) | +| `--client-secret` | C1 OAuth client secret | +| `--skip-full-sync` | Disable full sync in daemon mode | + +### Provisioning + +| Flag | Description | +|------|-------------| +| `--provisioning` | Enable provisioning mode | +| `--grant-entitlement` | Entitlement ID to grant | +| `--grant-principal` | Resource ID receiving grant | +| `--revoke-grant` | Grant ID to revoke | + +### Account management + +| Flag | Description | +|------|-------------| +| `--create-account-login` | Login for new account | +| `--create-account-email` | Email for new account | +| `--delete-resource` | Resource ID to delete | +| `--rotate-credentials` | Resource ID for rotation | + +### Targeted sync + +| Flag | Description | +|------|-------------| +| `--sync-resources` | Specific resource IDs to sync | +| `--sync-resource-types` | Resource types to sync | +| `--skip-entitlements-and-grants` | Skip E&G during sync | + +--- + +## Environment variables + +All flags map to `BATON_` prefixed env vars: + +```bash +--domain -> BATON_DOMAIN +--api-key -> BATON_API_KEY +--base-dn -> BATON_BASE_DN +--client-id -> BATON_CLIENT_ID +``` + +Rules: +- Prefix: `BATON_` +- Dashes become underscores +- Case insensitive + +--- + +## Config file + +```yaml +# ~/.baton/config.yaml +domain: example.okta.com +api-token: "00abc123..." +log-level: debug + +# Arrays +skip-groups: + - "Test Group" + - "Temp Users" +``` + +Checked in order: +1. `--config` flag path +2. `./baton.yaml` +3. `~/.baton/config.yaml` + +--- + +## Field types (for connector authors) + +### StringField + +```go +field.StringField("domain", + field.WithRequired(true), + field.WithDescription("Your Okta domain"), +) +``` + +### BoolField + +```go +field.BoolField("ldaps", + field.WithDefaultValue(false), +) +``` + +### IntField + +```go +field.IntField("port", + field.WithDefaultValue(389), +) +``` + +### StringSliceField + +```go +field.StringSliceField("skip-groups", + field.WithDescription("Groups to exclude"), +) +``` + +CLI: `--skip-groups "Group1" --skip-groups "Group2"` +Env: `BATON_SKIP_GROUPS="Group1,Group2"` + +### SelectField (enum) + +```go +field.SelectField("auth-type", []string{"token", "oauth", "basic"}, + field.WithDefaultValue("token"), +) +``` + +--- + +## Field options + +| Option | Purpose | +|--------|---------| +| `WithRequired(true)` | Must be provided | +| `WithIsSecret(true)` | Masked in logs/UI | +| `WithDefaultValue(v)` | Default if not set | +| `WithHidden(true)` | Hidden from help | +| `WithPlaceholder(s)` | GUI placeholder text | +| `WithDescription(s)` | Help text | + +--- + +## Defining custom fields + +```go +func main() { + ctx := context.Background() + + fields := []field.SchemaField{ + field.StringField("domain", + field.WithRequired(true), + field.WithDescription("Okta domain (e.g., example.okta.com)"), + ), + field.StringField("api-token", + field.WithRequired(true), + field.WithIsSecret(true), + field.WithDescription("Okta API token"), + ), + field.StringSliceField("skip-groups"), + } + + cfg := &Config{} + c, err := cli.NewCobra(ctx, "baton-okta", cfg, + cli.WithSchema(fields...), + ) + // ... +} + +type Config struct { + Domain string `mapstructure:"domain"` + APIToken string `mapstructure:"api-token"` + SkipGroups []string `mapstructure:"skip-groups"` +} +``` + +Access in connector: +```go +func NewConnector(ctx context.Context, cfg *Config) (*Connector, error) { + client := NewClient(cfg.Domain, cfg.APIToken) + return &Connector{client: client, skipGroups: cfg.SkipGroups}, nil +} +``` diff --git a/rap/ref-faq.md b/rap/ref-faq.md new file mode 100644 index 0000000..8e3fbf2 --- /dev/null +++ b/rap/ref-faq.md @@ -0,0 +1,157 @@ +# ref-faq + +Common questions about baton connectors. + +--- + +## SDK tools + +**Q: What is baton-sdk vs cone vs conductorone-sdk-go?** + +| Tool | Purpose | User | +|------|---------|------| +| baton-sdk | Go SDK for building connectors | Connector developers | +| cone | CLI for C1 platform ops | End users (requests, approvals) | +| conductorone-sdk-go | Go SDK for C1 API | App integrators | + +Building a connector? Use baton-sdk. + +--- + +## Capabilities + +**Q: Do all connectors support provisioning?** + +No. Many are sync-only. Check specific connector's capability manifest. + +**Q: Where is the authoritative source for capabilities?** + +| Source | Purpose | +|--------|---------| +| Docs capabilities index | Human discovery | +| Connector binary + manifests | Runtime contract | + +--- + +## Run modes + +**Q: What are the run modes?** + +| Mode | Trigger | Behavior | +|------|---------|----------| +| One-shot | No `--client-id` | Runs once, produces c1z, exits | +| Daemon | `--client-id` provided | Continuous task processing | + +**Q: What is "service mode"?** + +Overloaded term: +1. Daemon mode (SDK feature) +2. OS service (deployment concern) +3. Marketing ("runs continuously") + +**Q: How to determine mode?** + +```mermaid +flowchart TD + Q{"--client-id provided?"} + Q -->|No| OS[One-shot] + Q -->|Yes| D[Daemon] +``` + +--- + +## Data + +**Q: What is a .c1z file?** + +SQLite + gzip containing access graph data from sync. Inspect with `baton` CLI. + +**Q: What happens during sync?** + +| Stage | Method | Purpose | +|-------|--------|---------| +| 1 | ResourceType() | Learn what types exist | +| 2 | List() | Fetch all instances | +| 3 | Entitlements() | What permissions each offers | +| 4 | Grants() | Who has each permission | + +SDK processes ALL types per stage, not type-by-type. + +--- + +## Authentication + +**Q: Which auth method to use?** + +Whatever target system requires: + +| Method | Use Case | +|--------|----------| +| API Key | Token-based APIs | +| Bearer Token | OAuth2 APIs | +| OAuth2 Client Credentials | Service-to-service | +| JWT Service Account | Google-style | +| LDAP Bind | Directory services | + +--- + +## Pagination + +**Q: How to handle pagination?** + +| Helper | Use Case | +|--------|----------| +| pagination.Token | Linear pagination | +| pagination.Bag | Nested (children within parents) | + +**Q: bag.Current() returns nil?** + +Expected on first call. Nil-check before accessing fields. + +--- + +## Caching + +**Q: Can I use sync.Map?** + +Yes. Recommended for thread-safe caching. Cache users in List(), use in Grants(). + +**Q: Package-level sync.Map?** + +ANTI-PATTERN. Use struct fields. Package-level persists across syncs in daemon mode. + +--- + +## Meta-connectors + +**Q: What are baton-http and baton-sql?** + +Configuration-driven connectors using YAML + CEL instead of Go code. + +| Connector | Target | +|-----------|--------| +| baton-http | Any REST API | +| baton-sql | Any SQL database | + +**Q: When to use?** + +Standard patterns, don't want to write Go. Won't handle all edge cases. + +--- + +## Troubleshooting + +**Q: Pagination loop detected?** + +Returning same token causes infinite loop. Check: +- Returning empty string when done +- Not modifying cursor +- API actually returning different cursors + +**Q: Empty sync results?** + +Check: +- Credentials valid +- Permissions sufficient +- Filters not too restrictive +- API returning data diff --git a/rap/ref-glossary.md b/rap/ref-glossary.md new file mode 100644 index 0000000..b50546c --- /dev/null +++ b/rap/ref-glossary.md @@ -0,0 +1,71 @@ +# ref-glossary + +Term definitions for Baton connector development. + +--- + +## Core Terms + +| Term | Definition | +|------|------------| +| **Baton** | The connector framework: Go SDK + individual connectors | +| **baton-sdk** | Go library that handles sync orchestration and pagination | +| **Connector** | Go binary that syncs access data from a system | +| **c1z** | Compressed sync output file (gzip SQLite) | +| **Meta-connector** | Configuration-driven connector (baton-http, baton-sql) | + +## Access Model + +| Term | Definition | +|------|------------| +| **Resource** | Entity in target system: user, group, role, app | +| **Resource Type** | Classification with traits (e.g., "user" with TRAIT_USER) | +| **Trait** | Resource classification: TRAIT_USER, TRAIT_GROUP, TRAIT_ROLE, TRAIT_APP | +| **Entitlement** | Permission that can be granted (e.g., "admin" on a group) | +| **Grant** | Assignment of entitlement to principal | +| **Principal** | Entity receiving grants (typically users) | + +## SDK Concepts + +| Term | Definition | +|------|------------| +| **ResourceSyncer** | Interface: ResourceType, List, Entitlements, Grants methods | +| **pagination.Token** | SDK type for page cursors | +| **pagination.Bag** | SDK type for nested pagination state | +| **uhttp** | SDK HTTP client with retries and rate limiting | +| **RawId** | Annotation carrying external system's identifier | + +## Sync Lifecycle + +| Term | Definition | +|------|------------| +| **Sync** | Reading access data from a system | +| **Uplift** | ConductorOne process transforming raw records to domain objects | +| **ID Correlation** | Matching resources across syncs using RawId | + +## Provisioning + +| Term | Definition | +|------|------------| +| **Provision** | Writing access changes (grant, revoke, create, delete) | +| **Grant** (operation) | Add entitlement to principal | +| **Revoke** | Remove entitlement from principal | +| **CreateAccount** | JIT provisioning - create user in target system | +| **DeleteResource** | Remove resource from target system | + +## Run Modes + +| Term | Definition | +|------|------------| +| **One-shot** | Run once, produce c1z file, exit | +| **Daemon** | Long-running, polls ConductorOne for tasks | +| **Hosted** | Run by ConductorOne infrastructure | + +## Meta-Connector Terms + +| Term | Definition | +|------|------------| +| **CEL** | Common Expression Language for data transformation | +| **items_path** | JSONPath to array in API response | +| **primary_key** | Column used for cursor pagination | +| **skip_if** | CEL condition to filter grants | diff --git a/rap/ref-sdk.md b/rap/ref-sdk.md new file mode 100644 index 0000000..f4e57c5 --- /dev/null +++ b/rap/ref-sdk.md @@ -0,0 +1,211 @@ +# ref-sdk + +SDK interface reference for baton-sdk. Interfaces your connector implements. + +--- + +## Core interfaces + +### ConnectorBuilder (entry point) + +```go +type ConnectorBuilder interface { + MetadataProvider + ValidateProvider + ResourceSyncers(ctx context.Context) []ResourceSyncer +} + +type ConnectorBuilderV2 interface { // Preferred + MetadataProvider + ValidateProvider + ResourceSyncers(ctx context.Context) []ResourceSyncerV2 +} + +type MetadataProvider interface { + Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) +} + +type ValidateProvider interface { + Validate(ctx context.Context) (annotations.Annotations, error) +} +``` + +--- + +### ResourceSyncer (sync data) + +```go +// V1 (legacy) +type ResourceSyncer interface { + ResourceType(ctx context.Context) *v2.ResourceType + List(ctx context.Context, parentResourceID *v2.ResourceId, + pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) + Entitlements(ctx context.Context, resource *v2.Resource, + pToken *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) + Grants(ctx context.Context, resource *v2.Resource, + pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) +} + +// V2 (preferred) +type ResourceSyncerV2 interface { + ResourceType(ctx context.Context) *v2.ResourceType + List(ctx context.Context, parentResourceID *v2.ResourceId, + opts resource.SyncOpAttrs) ([]*v2.Resource, *resource.SyncOpResults, error) + Entitlements(ctx context.Context, resource *v2.Resource, + opts resource.SyncOpAttrs) ([]*v2.Entitlement, *resource.SyncOpResults, error) + Grants(ctx context.Context, resource *v2.Resource, + opts resource.SyncOpAttrs) ([]*v2.Grant, *resource.SyncOpResults, error) +} +``` + +V2 differences: receives `SyncOpAttrs` with session store, returns structured `SyncOpResults`. + +--- + +### ResourceProvisioner (grant/revoke) + +```go +// V2 (preferred) +type ResourceProvisionerV2 interface { + ResourceSyncer + Grant(ctx context.Context, resource *v2.Resource, + entitlement *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) + Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) +} +``` + +Enables `CAPABILITY_PROVISION`. + +--- + +### AccountManager (user provisioning) + +```go +type AccountManager interface { + ResourceSyncer + CreateAccount(ctx context.Context, + accountInfo *v2.AccountInfo, + credentialOptions *v2.LocalCredentialOptions, + ) (CreateAccountResponse, []*v2.PlaintextData, annotations.Annotations, error) + CreateAccountCapabilityDetails(ctx context.Context, + ) (*v2.CredentialDetailsAccountProvisioning, annotations.Annotations, error) +} +``` + +Enables `CAPABILITY_ACCOUNT_PROVISIONING`. + +--- + +### ResourceManager (create/delete) + +```go +type ResourceManagerV2 interface { + ResourceSyncer + Create(ctx context.Context, resource *v2.Resource) (*v2.Resource, annotations.Annotations, error) + Delete(ctx context.Context, resourceId *v2.ResourceId, + parentResourceID *v2.ResourceId) (annotations.Annotations, error) +} +``` + +Enables `CAPABILITY_RESOURCE_CREATE`, `CAPABILITY_RESOURCE_DELETE`. + +--- + +## Pagination + +### Token (simple cursor) + +```go +type Token struct { + Token string // Opaque cursor, empty for first page + Size int // Requested page size +} +``` + +Usage in List(): +```go +resp, _ := client.ListUsers(ctx, pToken.Token, 100) +return resources, resp.NextCursor, nil, nil // Return next cursor +``` + +### Bag (nested pagination) + +```go +bag := &pagination.Bag{} +bag.Push(pagination.PageState{Token: "cursor", ResourceID: "parent-123"}) +state := bag.Pop() + +// Encode for return +nextToken, _ := bag.Marshal() +``` + +Use when paginating within nested resources. + +--- + +## Resource helpers + +```go +// Create user resource +resource.NewUserResource(name, resourceType, id, + resource.WithEmail(email, isPrimary), + resource.WithUserLogin(login), + resource.WithStatus(v2.UserTrait_Status_STATUS_ENABLED), +) + +// Create group resource +resource.NewGroupResource(name, resourceType, id, + resource.WithGroupProfile(description), +) + +// Create generic resource +resource.NewResource(name, resourceType, id, + resource.WithParentResourceID(parentID), + resource.WithAnnotation(&v2.ChildResourceType{ResourceTypeId: "child-type"}), +) +``` + +--- + +## Entitlement helpers + +```go +// Permission on a resource +entitlement.NewPermissionEntitlement(resource, "admin", + entitlement.WithDisplayName("Administrator"), + entitlement.WithGrantableTo(userResourceType), +) + +// Membership in a group +entitlement.NewAssignmentEntitlement(groupResource, "member", + entitlement.WithDisplayName("Member"), +) +``` + +--- + +## Grant helpers + +```go +grant.NewGrant( + resource, // Resource the entitlement belongs to + "permission", // Entitlement slug + principalID, // Who has the grant (*v2.ResourceId) +) +``` + +--- + +## Capabilities + +| Capability | Interface Required | +|------------|-------------------| +| `CAPABILITY_PROVISION` | ResourceProvisioner | +| `CAPABILITY_ACCOUNT_PROVISIONING` | AccountManager | +| `CAPABILITY_RESOURCE_CREATE` | ResourceManager | +| `CAPABILITY_RESOURCE_DELETE` | ResourceManager | +| `CAPABILITY_TARGETED_SYNC` | ResourceTargetedSyncer | +| `CAPABILITY_CREDENTIAL_ROTATION` | CredentialManager | +| `CAPABILITY_ACTIONS` | CustomActionManager | + +Capabilities declared in connector metadata, enabled by implementing interfaces.