diff --git a/.github/workflows/test-webhook-integrations.yml b/.github/workflows/test-webhook-integrations.yml new file mode 100644 index 0000000..412dc8d --- /dev/null +++ b/.github/workflows/test-webhook-integrations.yml @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: Apache-2.0 +name: Test webhook integrations +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + regression: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Install handler dependencies + run: | + npm ci --ignore-scripts --prefix cdp-analytics/segment/webhook + npm ci --ignore-scripts --prefix siem-security-analytics/splunk/webhook + npm ci --ignore-scripts --prefix webhook-infrastructure/svix/webhook + npm ci --ignore-scripts --prefix user-enrichment/clearbit/webhook + npm ci --ignore-scripts --prefix crm/pipedrive/webhook + - name: Run isolated regressions + run: node --test scripts/test-webhook-integrations.cjs diff --git a/_examples/_template-webhook/README.md b/_examples/_template-webhook/README.md index dc46451..10e3e49 100644 --- a/_examples/_template-webhook/README.md +++ b/_examples/_template-webhook/README.md @@ -40,9 +40,36 @@ The server listens on the port specified in `.env` (default 3000) and exposes: ## Configure Ory -1. In the Ory Console, configure an Action hook using the snippet in [`ory-actions.yaml`](ory-actions.yaml). -2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet) — adjust as needed. -3. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value declared in the Ory hook config. +1. Configure an Action hook using the snippet in [`ory-actions.yaml`](ory-actions.yaml). Attach `after` actions of the registration, login, and settings flows to the method-specific lists, not to the flow-level `after.hooks` lists: + - Registration: `selfservice.flows.registration.after..hooks` for each sign-up method you enable, such as `password` and `oidc`. On Ory Network, the `password` list contains `session`; keep it last. + - Login: `selfservice.flows.login.after..hooks` for each sign-in method. + - Profile updates: `selfservice.flows.settings.after.profile.hooks`. + + Recovery and verification have only flow-level lists, such as `selfservice.flows.verification.after.hooks`. See [Attach `after` actions to authentication methods](https://www.ory.com/docs/kratos/hooks/configure-hooks#attach-after-actions-to-authentication-methods). +2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet) — adjust as needed. Ory Network doesn't accept `file://` bodies, so set `body` to `base64://` followed by the Base64-encoded template. Read optional fields with `std.get`: Jsonnet fails on a missing field, and Ory then drops the request or fails the flow. The webhook context never contains `metadata_admin`, and its request headers don't include `X-Forwarded-For`; read the client IP address from `True-Client-Ip`. +3. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value declared in the Ory hook config. Ory webhooks authenticate only with `api_key` or `basic_auth`. + +## Webhook behavior + +- `response.ignore: true` sends the request in the background. Ory doesn't wait for the handler and ignores its response. Use it for every webhook that only notifies another system. +- Without `response.ignore` or `response.parse`, a registration or settings webhook runs after the identity is saved. A response with status `400` or higher still fails the user's request, even though the change is saved. +- With `response.parse: true`, a registration or settings webhook runs before the identity is saved. In registration, `identity.id` is `00000000-0000-0000-0000-000000000000` at that point, so don't use it as a key in other systems. A `200` response with an `identity` object changes the identity; each field you return replaces the whole field, for example all of `metadata_public`. In the login flow, those changes apply only to that login's response and aren't saved. +- To block the flow and tell the user why, set `response.parse: true` and respond with a `4xx` status other than `429`, with this body: + + ```json + { + "messages": [ + { + "instance_ptr": "#/traits/email", + "messages": [{ "id": 10000001, "text": "Only employees can sign up.", "type": "error", "context": {} }] + } + ] + } + ``` + + Use `#/` as `instance_ptr` for a message about the whole form. A `4xx` response without this shape fails the flow without showing the user a reason. +- Ory retries a request that can't be delivered, or that receives a `429` or `5xx` status other than `501`, for up to three attempts in total within a few seconds. All attempts for one trigger carry the same `Ory-Webhook-Trigger-Id` header. +- The handler must respond within about 30 seconds. Detailed setup with screenshots: see the [docs page](https://ory.com/docs/integrates-with/). diff --git a/_examples/_template-webhook/jsonnet/identity.jsonnet b/_examples/_template-webhook/jsonnet/identity.jsonnet index 0550783..0f1ba73 100644 --- a/_examples/_template-webhook/jsonnet/identity.jsonnet +++ b/_examples/_template-webhook/jsonnet/identity.jsonnet @@ -1,8 +1,12 @@ -// Default body template: forward the identity payload to your webhook handler. -// Override this file when your handler needs a different shape. +// Default body template: forward the identity to your webhook handler. +// Override this file when your handler needs a different shape. Read optional +// fields with std.get: Jsonnet fails on a missing field, and Ory then drops +// the request. The webhook context never contains metadata_admin. function(ctx) { - identity_id: ctx.identity.id, - email: ctx.identity.traits.email, - traits: ctx.identity.traits, - created_at: ctx.identity.created_at, + identity: { + id: ctx.identity.id, + traits: ctx.identity.traits, + created_at: ctx.identity.created_at, + }, + client_ip: std.get(ctx.request_headers, 'True-Client-Ip', [null])[0], } diff --git a/_examples/_template-webhook/ory-actions.yaml b/_examples/_template-webhook/ory-actions.yaml index 0bbae09..530884d 100644 --- a/_examples/_template-webhook/ory-actions.yaml +++ b/_examples/_template-webhook/ory-actions.yaml @@ -1,14 +1,37 @@ -hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com//sync-user - method: POST - body: file://./jsonnet/identity.jsonnet - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header - response: - ignore: true # Set to false + add `parse: true` if your handler returns identity metadata. +# Ory Network doesn't accept file:// bodies. Encode the template with +# `base64 < jsonnet/identity.jsonnet | tr -d '\n'` and paste it after base64://. +# +# response.ignore: true sends the request in the background. To change the +# identity or block the flow instead, replace it with response.parse: true and +# return the response shapes described in the README. + +selfservice: + flows: + registration: + after: + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com//sync-user + method: POST + body: base64:// + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + response: + ignore: true + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks + # The password list is aliased into the oidc, code, passkey, and webauthn + # lists. Alias it into any other method you enable, such as saml. diff --git a/_examples/_template-webhook/registry.entry.yaml.example b/_examples/_template-webhook/registry.entry.yaml.example index c444139..802fd29 100644 --- a/_examples/_template-webhook/registry.entry.yaml.example +++ b/_examples/_template-webhook/registry.entry.yaml.example @@ -13,7 +13,7 @@ useCase: >- Two to three sentences describing who this is for and why they would reach for this integration. Concrete enough that a reader can self-identify whether it applies to them. coreFunctionality: |- - - Endpoints exposed (sync vs async, can_interrupt, etc.) and what each one does. + - Endpoints exposed (async with response.ignore, or sync with response.parse) and what each one does. - Auth mechanism (X-Webhook-Secret, HMAC for vendor callbacks). - Identity-side state shape (metadata_public / metadata_admin keys). - Non-obvious operational facts (rate limits, idempotency, retry behavior). diff --git a/_examples/_template-webhook/webhook/server.ts b/_examples/_template-webhook/webhook/server.ts index a5fb432..a399a58 100644 --- a/_examples/_template-webhook/webhook/server.ts +++ b/_examples/_template-webhook/webhook/server.ts @@ -25,6 +25,7 @@ if (!ORY_WEBHOOK_SECRET) { // Minimal shape of the body the Ory Action POSTs. Define a tighter type per // integration once you know which jsonnet fields you actually need. interface OryWebhookBody { + client_ip?: string | null; identity?: { id?: string; traits?: { @@ -83,7 +84,8 @@ app.post( if (!vendorRes.ok) { const detail = await vendorRes.text(); console.error(`Vendor API ${vendorRes.status}: ${detail}`); - // 5xx tells Ory to retry per the action's retry policy; 4xx would be terminal. + // Ory retries 429 and 5xx responses other than 501, up to three attempts + // in total within a few seconds. Other 4xx responses aren't retried. res.status(502).json({ error: "sync_failed" }); return; } diff --git a/cdp-analytics/amplitude/README.md b/cdp-analytics/amplitude/README.md index 4e0c8aa..a0f27f8 100644 --- a/cdp-analytics/amplitude/README.md +++ b/cdp-analytics/amplitude/README.md @@ -13,6 +13,8 @@ Configure an Ory Action on each lifecycle hook (async, `response.ignore: true`) No webhook handler required — Amplitude's HTTP V2 API accepts authenticated POSTs directly. +Attach each action to the method-specific lists, such as `selfservice.flows.registration.after.password.hooks` and `selfservice.flows.login.after.password.hooks`, and repeat it for every method you enable, such as `oidc` or `code`. On Ory Network, the registration `password` list contains `session`; keep it last. Verification has only the flow-level `selfservice.flows.verification.after.hooks` list. See [Attach `after` actions to authentication methods](https://www.ory.com/docs/kratos/hooks/configure-hooks#attach-after-actions-to-authentication-methods). + ## Setup outline 1. In Amplitude → **Settings** → **Projects** → select project → copy the **API Key** (server-side). @@ -21,6 +23,7 @@ No webhook handler required — Amplitude's HTTP V2 API accepts authenticated PO - Method: POST. - Body Jsonnet: `{ "api_key": "", "events": [{ "user_id": ctx.identity.id, "event_type": "ory_registration", "user_properties": { ... } }] }`. - `response.ignore: true`. + - Read optional traits with `std.get`, because Jsonnet fails on a missing field and Ory then drops the request. 3. For EU data residency, use `https://api.eu.amplitude.com/2/httpapi`. ## Notable diff --git a/cdp-analytics/mailchimp/README.md b/cdp-analytics/mailchimp/README.md index edd5d4b..18f318f 100644 --- a/cdp-analytics/mailchimp/README.md +++ b/cdp-analytics/mailchimp/README.md @@ -12,9 +12,9 @@ Sync newly registered Ory Network users into a Mailchimp **audience** (subscribe ## How it works 1. User completes the registration self-service flow. -2. Ory triggers an `after` action registered on `flows.registration.after.hooks`. +2. Ory triggers the `after` action from the list of the sign-up method the user chose, for example `selfservice.flows.registration.after.password.hooks`. 3. The action evaluates a Jsonnet template that maps Ory identity traits to Mailchimp merge fields and tags. -4. Ory POSTs to the Mailchimp Marketing API to add or update the subscriber in the configured audience. +4. Ory POSTs to the Mailchimp Marketing API to add the subscriber to the configured audience. The webhook URL is static, so this action can only add new members. To update an existing member when the user changes their profile, point a settings action at your own handler, which finds the member and updates it. ## Setup @@ -22,7 +22,7 @@ The full walkthrough lives in the [Ory docs page above](https://www.ory.com/docs 1. Create a Mailchimp **Marketing API key** (distinct from the Mandrill key used for transactional email). 2. Identify the target audience (list) ID from the Mailchimp dashboard. -3. Create an Ory Action of type `web_hook` on `flows.registration.after.hooks` pointing at `https://{dc}.api.mailchimp.com/3.0/lists/{list_id}/members`, with HTTP basic auth (`anystring:{api_key}`) and a Jsonnet body template that maps `identity.traits.email` and `identity.traits.name` into Mailchimp merge fields. +3. Create an Ory Action of type `web_hook` with `response.ignore: true` pointing at `https://{dc}.api.mailchimp.com/3.0/lists/{list_id}/members`, with `basic_auth` (`anystring:{api_key}`) and a Jsonnet body template that maps `identity.traits.email` and the name traits into Mailchimp merge fields. Read optional traits with `std.get`, because Jsonnet fails on a missing field and Ory then drops the request. Attach the action to `selfservice.flows.registration.after..hooks` for each sign-up method you enable, such as `password` and `oidc`, not to the flow-level `after.hooks` list. On Ory Network, the `password` list contains `session`; keep it last. See [Attach `after` actions to authentication methods](https://www.ory.com/docs/kratos/hooks/configure-hooks#attach-after-actions-to-authentication-methods). ## License diff --git a/cdp-analytics/mailchimp/registry.entry.yaml b/cdp-analytics/mailchimp/registry.entry.yaml index c481606..a81f598 100644 --- a/cdp-analytics/mailchimp/registry.entry.yaml +++ b/cdp-analytics/mailchimp/registry.entry.yaml @@ -17,7 +17,7 @@ useCase: >- the right Mailchimp audience, tagged with traits collected at registration, without writing a separate sync job or relying on manual exports. coreFunctionality: |- - - Implemented as an Ory Actions webhook on `flows.registration.after.hooks` that calls Mailchimp's Lists/Audiences API. + - Implemented as an async Ory Actions webhook (response.ignore: true) in the registration method lists, for example `selfservice.flows.registration.after.password.hooks`, that calls Mailchimp's Lists/Audiences API to add members. - Jsonnet template maps Ory identity traits (email, name, custom traits) into Mailchimp merge fields and audience tags. - Synchronous or asynchronous webhook execution depending on whether registration should block on the Mailchimp call. - Authentication via Mailchimp Marketing API key (separate from the Mandrill API key used by the transactional integration). diff --git a/cdp-analytics/mixpanel/README.md b/cdp-analytics/mixpanel/README.md index 40573d1..e16c9e9 100644 --- a/cdp-analytics/mixpanel/README.md +++ b/cdp-analytics/mixpanel/README.md @@ -9,14 +9,16 @@ ## How it works -Configure an Ory Action on each lifecycle hook (async, `response.ignore: true`) with a Jsonnet body that emits a Mixpanel event payload, POSTed directly to `https://api.mixpanel.com/track` (or `https://api-eu.mixpanel.com/track` for EU residency). +Configure an Ory Action on each lifecycle hook (async, `response.ignore: true`) with a Jsonnet body that emits a Mixpanel event payload, POSTed directly to `https://api.mixpanel.com/track` (or `https://api-eu.mixpanel.com/track` for EU residency). The Project Token in the event payload authenticates the request. + +Attach each action to the method-specific lists, such as `selfservice.flows.registration.after.password.hooks` and `selfservice.flows.login.after.password.hooks`, and repeat it for every method you enable, such as `oidc` or `code`. On Ory Network, the registration `password` list contains `session`; keep it last. Verification has only the flow-level `selfservice.flows.verification.after.hooks` list. See [Attach `after` actions to authentication methods](https://www.ory.com/docs/kratos/hooks/configure-hooks#attach-after-actions-to-authentication-methods). ## Setup outline 1. In Mixpanel → **Project Settings** → copy the **Project Token**. 2. Configure Ory Actions for each event: - URL: `https://api.mixpanel.com/track?ip=0`. - - Method: POST with `Content-Type: application/json`. + - Method: POST. Ory sends the body as `application/json`. - Body Jsonnet: `[{ "event": "ory_login", "properties": { "token": "", "distinct_id": ctx.identity.id, "$insert_id": ctx.flow.id, ... } }]`. - `response.ignore: true`. 3. For user-property updates (identity trait changes), use Mixpanel's `/engage` endpoint with `$set` operations. @@ -26,7 +28,7 @@ Configure an Ory Action on each lifecycle hook (async, `response.ignore: true`) - **Stable `distinct_id`** — use Ory's identity id, not email. Mixpanel identity merging breaks if `distinct_id` changes. - **`$insert_id`** for deduplication — set to Ory's flow id so retries don't double-count events. - Mixpanel's IP geolocation defaults to enabled (`ip=1`); pass `?ip=0` to disable when the IP belongs to your server, not the user. -- For **Service Accounts** (preferred over Project Token for server-to-server): use HTTP Basic auth with the service-account username/password. +- `/track` accepts only the Project Token. Service Account authentication (HTTP Basic) works with the `/import` endpoint, not with `/track`. ## Status diff --git a/cdp-analytics/mixpanel/registry.entry.yaml b/cdp-analytics/mixpanel/registry.entry.yaml index adb14aa..3bb0272 100644 --- a/cdp-analytics/mixpanel/registry.entry.yaml +++ b/cdp-analytics/mixpanel/registry.entry.yaml @@ -9,14 +9,14 @@ type: config maintainedBy: Community contributors description: >- Send Ory authentication events into Mixpanel (product analytics) via the Ingestion API. Ory Action POSTs - directly to Mixpanel's /track endpoint — no webhook handler needed; Project Token (or Service Account) + directly to Mixpanel's /track endpoint — no webhook handler needed; the Project Token in the payload authenticates. useCase: >- A consumer product wants identity events (registration / login / verification / MFA) in Mixpanel funnels and retention dashboards alongside in-product events — without standing up a webhook handler. coreFunctionality: |- - Pattern: Ory Action async POSTs directly to https://api.mixpanel.com/track?ip=0 (or api-eu.mixpanel.com for EU residency) — no handler in between. - - Authentication: Project Token in the event payload (properties.token), OR HTTP Basic with Service Account credentials (preferred for server-to-server). + - Authentication: Project Token in the event payload (properties.token). Service Account (HTTP Basic) authentication works with /import, not /track. - Stable distinct_id: use Ory's identity id, NOT email — Mixpanel identity merges break if distinct_id changes. - $insert_id for deduplication: set to Ory's flow id so retries don't double-count events. - ip=0 disables Mixpanel's IP geolocation when the request comes from your server, not the user. diff --git a/cdp-analytics/mparticle/README.md b/cdp-analytics/mparticle/README.md index 9103e64..b23f182 100644 --- a/cdp-analytics/mparticle/README.md +++ b/cdp-analytics/mparticle/README.md @@ -11,14 +11,16 @@ Configure an Ory Action on each lifecycle hook (async, `response.ignore: true`) with a Jsonnet body that emits an mParticle Events API payload, POSTed directly to `https://s2s.mparticle.com/v2/events` with HTTP Basic auth (mParticle API Key + Secret). +Attach each action to the method-specific lists, such as `selfservice.flows.registration.after.password.hooks` and `selfservice.flows.login.after.password.hooks`, and repeat it for every method you enable, such as `oidc` or `code`. On Ory Network, the registration `password` list contains `session`; keep it last. Verification has only the flow-level `selfservice.flows.verification.after.hooks` list. See [Attach `after` actions to authentication methods](https://www.ory.com/docs/kratos/hooks/configure-hooks#attach-after-actions-to-authentication-methods). + ## Setup outline 1. In mParticle → **Setup** → **Inputs** → create a **Custom Feed** (Platform: Custom). Copy the **API Key** and **API Secret**. 2. Configure Ory Actions for each event: - URL: `https://s2s.mparticle.com/v2/events`. - - Method: POST with `Content-Type: application/json`. - - Auth: HTTP Basic with `API Key:API Secret`. - - Body Jsonnet: `{ "events": [{ "event_type": "custom_event", "data": { "event_name": "ory_registration", "custom_event_type": "other" } }], "user_identities": { "customer_id": ctx.identity.id, "email": ctx.identity.traits.email }, "environment": "production" }`. + - Method: POST. Ory sends the body as `application/json`. + - Auth: `basic_auth` with `user` set to the API Key and `password` set to the API Secret. + - Body Jsonnet: `{ "events": [{ "event_type": "custom_event", "data": { "event_name": "ory_registration", "custom_event_type": "other" } }], "user_identities": { "customer_id": ctx.identity.id, "email": std.get(ctx.identity.traits, 'email', null) }, "environment": "production" }`. - `response.ignore: true`. 3. Configure outputs in mParticle to route Ory events to whichever downstream destinations you use. diff --git a/cdp-analytics/segment/README.md b/cdp-analytics/segment/README.md index 1690b5b..917320d 100644 --- a/cdp-analytics/segment/README.md +++ b/cdp-analytics/segment/README.md @@ -1,6 +1,6 @@ # Segment Integration for Ory Network -Send `identify` and `track` calls to Segment when users register or log in via Ory. Uses Ory Actions webhooks to call Segment's HTTP Tracking API. +Send `identify` and `track` calls to Segment when users register, log in, or update their profile via Ory. Uses Ory Actions webhooks to call Segment's HTTP Tracking API. **Platform:** Ory Network (managed cloud) **Docs page:** [ory.com/docs/integrates-with/cdp-analytics/segment](https://www.ory.com/docs/integrates-with/cdp-analytics/segment) — full guide: [ory.com/docs/actions/integrations/segment](https://www.ory.com/docs/actions/integrations/segment) @@ -9,13 +9,18 @@ Send `identify` and `track` calls to Segment when users register or log in via O ## Overview -This integration uses Ory Actions **post-registration** and **post-login** webhooks to send events to Segment's HTTP Tracking API. Registration triggers an `identify` call (to create/update the user profile in Segment) and a `track` call (to record the "User Registered" event). Login triggers a `track` call for "User Logged In". +This integration uses Ory Actions **post-registration**, **post-login**, and **post-settings** webhooks to send events to Segment's HTTP Tracking API. Registration triggers an `identify` call (to create or update the user profile in Segment) and a `track` call ("User Registered"). Login triggers a `track` call ("User Logged In"). Profile changes trigger an `identify` call. + +You can run it two ways: + +- **With the reference handler** in [`webhook/`](webhook/) (recommended). The actions in [`ory-actions.yaml`](ory-actions.yaml) send [`jsonnet/event.jsonnet`](jsonnet/event.jsonnet) to the handler, which calls Segment. +- **Directly**, with one action per Segment call and the Jsonnet templates below. No handler to run. **Flow:** -1. User registers or logs in via Ory. -2. Ory Actions fires the appropriate webhook. -3. The Jsonnet template maps the identity to a Segment API payload. +1. User registers, signs in, or changes their profile via Ory. +2. Ory Actions fires the matching webhook in the background (`response.ignore: true`), so the flow doesn't wait for Segment. +3. The Jsonnet template maps the identity to the request body. 4. Segment receives and routes the event to connected destinations. --- @@ -29,108 +34,122 @@ This integration uses Ory Actions **post-registration** and **post-login** webho | **Auth** | HTTP Basic Auth (Write Key as username, empty password) | | **Content-Type** | `application/json` | -The Write Key is base64-encoded as `WRITE_KEY:` (note the trailing colon) and sent as `Basic `. - To get your Write Key: **Segment > Sources > Your Source > Settings > API Keys**. --- -## Ory Actions Webhook Configuration +## Option A: Reference handler + +```bash +cd webhook/ +cp .env.example .env +# Fill in ORY_WEBHOOK_SECRET and SEGMENT_WRITE_KEY. +npm install +npm start +``` + +The handler exposes `POST /segment/registration`, `POST /segment/login`, and `POST /segment/settings`, plus `GET /health`. Configure the actions from [`ory-actions.yaml`](ory-actions.yaml) and set `ORY_WEBHOOK_SECRET` to the `X-Webhook-Secret` value in the action's `auth` block. The handler takes the client IP address from the `True-Client-Ip` request header that Ory Network sets; Ory doesn't forward `X-Forwarded-For` to webhooks. + +## Option B: Call Segment directly + +Ory Actions authenticate with `basic_auth` or `api_key` only. Use `basic_auth` with the Write Key as `user` and an empty `password`. Ory Network ignores a webhook's `headers` setting, so don't put the `Authorization` header there. + +Encode each template with `base64 < .jsonnet | tr -d '\n'` and set it as `body: base64://`. Ory Network doesn't accept `file://` URLs. -### Post-Registration: Identify + Track +### Post-registration: identify + track + +Attach the actions to the list of each sign-up method you enable. Ory runs the flow-level `registration.after.hooks` list only for methods without their own list, and on Ory Network the password list already contains `session`. Keep `session` last: ```yaml selfservice: flows: registration: after: - password: + password: # Repeat for oidc, code, passkey, and other sign-up methods. hooks: - hook: web_hook config: url: https://api.segment.io/v1/identify method: POST - headers: - Content-Type: application/json - Authorization: "Basic YOUR_BASE64_ENCODED_WRITE_KEY" - body: file:///etc/config/ory/segment_identify.jsonnet - can_interrupt: false + body: base64:// + auth: + type: basic_auth + config: + user: + password: "" response: ignore: true - hook: web_hook config: url: https://api.segment.io/v1/track method: POST - headers: - Content-Type: application/json - Authorization: "Basic YOUR_BASE64_ENCODED_WRITE_KEY" - body: file:///etc/config/ory/segment_track_registration.jsonnet - can_interrupt: false + body: base64:// + auth: + type: basic_auth + config: + user: + password: "" response: ignore: true + - hook: session ``` -### Post-Login: Track +These actions run after Ory saves the identity, so `ctx.identity.id` is the real identity ID. Don't add `response.parse: true`: a parsing registration action runs before the identity is saved, when its ID is `00000000-0000-0000-0000-000000000000`. + +### Post-login: track ```yaml selfservice: flows: login: after: - password: + password: # Repeat for oidc, code, passkey, and other sign-in methods. hooks: - hook: web_hook config: url: https://api.segment.io/v1/track method: POST - headers: - Content-Type: application/json - Authorization: "Basic YOUR_BASE64_ENCODED_WRITE_KEY" - body: file:///etc/config/ory/segment_track_login.jsonnet - can_interrupt: false + body: base64:// + auth: + type: basic_auth + config: + user: + password: "" response: ignore: true ``` -### Generating the Basic Auth Header - -```bash -# Replace YOUR_SEGMENT_WRITE_KEY with your actual write key -echo -n 'YOUR_SEGMENT_WRITE_KEY:' | base64 -``` - --- ## Jsonnet Templates +Ory passes the context as the `ctx` argument of the template function. Jsonnet fails on a field that doesn't exist, and a failing template drops the request, so read optional traits and headers with `std.get`. + ### Identify (Post-Registration) Save as `segment_identify.jsonnet`: ```jsonnet -local ctx = std.extVar('ctx'); - -local identity = ctx.identity; -local traits = identity.traits; - -{ - userId: identity.id, - traits: { - email: traits.email, - [if std.objectHas(traits, 'name') && std.objectHas(traits.name, 'first') then 'firstName']: traits.name.first, - [if std.objectHas(traits, 'name') && std.objectHas(traits.name, 'last') then 'lastName']: traits.name.last, - [if std.objectHas(traits, 'phone') then 'phone']: traits.phone, - createdAt: identity.created_at, - orySchemaId: identity.schema_id, - }, - context: { - library: { - name: 'ory-actions', - version: '1.0.0', +function(ctx) + local traits = ctx.identity.traits; + local name = std.get(traits, 'name', null); + local nameFields = if name == null then {} else name; + std.prune({ + userId: ctx.identity.id, + traits: { + email: std.get(traits, 'email', null), + firstName: std.get(nameFields, 'first', null), + lastName: std.get(nameFields, 'last', null), + phone: std.get(traits, 'phone', null), + createdAt: ctx.identity.created_at, + orySchemaId: ctx.identity.schema_id, }, - }, - timestamp: identity.created_at, -} + context: { + library: { name: 'ory-actions', version: '1.0.0' }, + ip: std.get(ctx.request_headers, 'True-Client-Ip', [null])[0], + }, + timestamp: ctx.identity.created_at, + }) ``` ### Track — User Registered (Post-Registration) @@ -138,31 +157,21 @@ local traits = identity.traits; Save as `segment_track_registration.jsonnet`: ```jsonnet -local ctx = std.extVar('ctx'); - -local identity = ctx.identity; -local traits = identity.traits; - -{ - userId: identity.id, - event: 'User Registered', - properties: { - email: traits.email, - registrationMethod: 'password', - schemaId: identity.schema_id, - identityState: identity.state, - }, - context: { - library: { - name: 'ory-actions', - version: '1.0.0', +function(ctx) + std.prune({ + userId: ctx.identity.id, + event: 'User Registered', + properties: { + schemaId: ctx.identity.schema_id, + identityState: ctx.identity.state, + flowId: ctx.flow.id, }, - traits: { - email: traits.email, + context: { + library: { name: 'ory-actions', version: '1.0.0' }, + ip: std.get(ctx.request_headers, 'True-Client-Ip', [null])[0], }, - }, - timestamp: identity.created_at, -} + timestamp: ctx.identity.created_at, + }) ``` ### Track — User Logged In (Post-Login) @@ -170,29 +179,19 @@ local traits = identity.traits; Save as `segment_track_login.jsonnet`: ```jsonnet -local ctx = std.extVar('ctx'); - -local identity = ctx.identity; -local traits = identity.traits; - -{ - userId: identity.id, - event: 'User Logged In', - properties: { - email: traits.email, - loginMethod: 'password', - schemaId: identity.schema_id, - }, - context: { - library: { - name: 'ory-actions', - version: '1.0.0', +function(ctx) + std.prune({ + userId: ctx.identity.id, + event: 'User Logged In', + properties: { + loginMethods: [m.method for m in std.get(ctx.session, 'authentication_methods', [])], + flowId: ctx.flow.id, }, - traits: { - email: traits.email, + context: { + library: { name: 'ory-actions', version: '1.0.0' }, + ip: std.get(ctx.request_headers, 'True-Client-Ip', [null])[0], }, - }, -} + }) ``` --- @@ -245,7 +244,10 @@ Once events flow into Segment, you can route them to any connected destination: | Issue | Resolution | |---|---| -| 401 Unauthorized | Verify the Base64-encoded Write Key is correct (include trailing colon before encoding). | -| Events not appearing | Check Segment Source Debugger for incoming events. Verify webhook is under the correct flow. | -| Missing traits | Ensure identity schema trait paths match what the Jsonnet template expects. | -| Duplicate identify calls | This is generally fine; Segment de-duplicates by `userId`. | +| 401 Unauthorized | Verify the Write Key in the `basic_auth` `user` field (direct path) or `SEGMENT_WRITE_KEY` (handler). | +| Events not appearing | Check Segment Source Debugger for incoming events. Verify the action is in the method-specific list, for example `registration.after.password.hooks`, not only in the flow-level `after.hooks`. | +| Missing traits or no request at all | A template that reads a missing field fails and Ory drops the request. Read optional traits with `std.get`. | +| Brief Segment errors | Ory retries a delivery that fails or receives 429 or a 5xx status other than 501, up to three attempts within a few seconds. Longer outages drop the event. | +| Duplicate identify calls | This is generally fine; Segment de-duplicates retries by their stable `messageId`. | + +The handler acknowledges successful vendor calls with `200` and returns `502` on failure so Ory can retry without delaying the user flow. Retries are bounded; use a durable queue for longer outages. Segment retries reuse message IDs; Pipedrive activity retries may produce duplicates after an ambiguous timeout. diff --git a/cdp-analytics/segment/jsonnet/event.jsonnet b/cdp-analytics/segment/jsonnet/event.jsonnet index 0594be4..68d3bce 100644 --- a/cdp-analytics/segment/jsonnet/event.jsonnet +++ b/cdp-analytics/segment/jsonnet/event.jsonnet @@ -1,17 +1,15 @@ -// Body for the async Segment Ory Action hooks (registration, login, -// settings). The handler distinguishes events by endpoint path; this single -// body shape is used for all three. +// Body for the Segment Ory Actions (registration, login, settings). The +// handler selects the Segment call by endpoint path. function(ctx) { identity: { id: ctx.identity.id, traits: ctx.identity.traits, - metadata_public: ctx.identity.metadata_public, - created_at: ctx.identity.created_at, + created_at: std.get(ctx.identity, 'created_at', null), }, flow: { id: ctx.flow.id, type: ctx.flow.type, }, - request_headers: ctx.request_headers, - request_url: ctx.request_url, + client_ip: std.get(ctx.request_headers, 'True-Client-Ip', [null])[0], + user_agent: std.get(ctx.request_headers, 'User-Agent', [null])[0], } diff --git a/cdp-analytics/segment/ory-actions.yaml b/cdp-analytics/segment/ory-actions.yaml index ad356ad..838f56d 100644 --- a/cdp-analytics/segment/ory-actions.yaml +++ b/cdp-analytics/segment/ory-actions.yaml @@ -1,60 +1,84 @@ -# Segment CDP forwarding hooks. +# Segment CDP forwarding actions. # -# All three hooks are fire-and-forget (response.ignore: true, can_interrupt: -# false). The handler returns 200 to Ory immediately and talks to Segment's -# HTTP Tracking API in the background. Segment availability never blocks -# user flows. +# The actions set response.ignore: true, so Ory doesn't wait for the +# handler and Segment availability never blocks user flows. Ory retries a +# delivery that fails or receives 429 or a 5xx status other than 501, up to +# three attempts in total. +# +# Encode the body template with `base64 < jsonnet/event.jsonnet | tr -d '\n'`. +# Ory Network doesn't accept file:// URLs. selfservice: flows: registration: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/segment/registration - method: POST - body: file://./jsonnet/event.jsonnet - can_interrupt: false - response: - ignore: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + # The password list is aliased into the oidc, code, passkey, and webauthn + # lists. Alias it into any other method you enable, such as saml. + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/segment/registration + method: POST + body: base64:// + response: + ignore: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks login: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/segment/login - method: POST - body: file://./jsonnet/event.jsonnet - can_interrupt: false - response: - ignore: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + # The password list is aliased into the oidc, code, passkey, and webauthn + # lists. Alias it into any other method you enable, such as saml. + password: + hooks: &login_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/segment/login + method: POST + body: base64:// + response: + ignore: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + oidc: + hooks: *login_after_hooks + code: + hooks: *login_after_hooks + passkey: + hooks: *login_after_hooks + webauthn: + hooks: *login_after_hooks settings: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/segment/settings - method: POST - body: file://./jsonnet/event.jsonnet - can_interrupt: false - response: - ignore: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + profile: + hooks: + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/segment/settings + method: POST + body: base64:// + response: + ignore: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header diff --git a/cdp-analytics/segment/registry.entry.yaml b/cdp-analytics/segment/registry.entry.yaml index 5734512..2b17cf6 100644 --- a/cdp-analytics/segment/registry.entry.yaml +++ b/cdp-analytics/segment/registry.entry.yaml @@ -17,11 +17,11 @@ useCase: >- data warehouse) gets the same view of who registered, who logged in, and whose profile changed, without making Segment a critical-path dependency of authentication. coreFunctionality: |- - - Three async Ory Action endpoints (/segment/registration, /segment/login, /segment/settings); each returns 200 immediately so user flows are never blocked. + - Three Ory Action endpoints (/segment/registration, /segment/login, /segment/settings) behind actions with response.ignore: true, so Ory doesn't wait for them and user flows are never blocked. - Registration → identify (with traits) + track "User Registered" in parallel. - Login → track "User Logged In". - Settings → identify to refresh traits. - - HTTP Basic auth to Segment (write key as username, empty password); IP and User-Agent passed through Segment's context for server-side / client-side correlation. + - HTTP Basic auth to Segment (write key as username, empty password); client IP (from True-Client-Ip) and User-Agent passed through Segment's context for server-side / client-side correlation. - Fire-and-forget: Segment outages and 5xx responses are logged but never propagate to Ory. oryMechanism: - actions diff --git a/cdp-analytics/segment/webhook/server.ts b/cdp-analytics/segment/webhook/server.ts index 39dd6ea..236041c 100644 --- a/cdp-analytics/segment/webhook/server.ts +++ b/cdp-analytics/segment/webhook/server.ts @@ -2,10 +2,10 @@ // // Segment <> Ory Actions webhook handler. // -// Three async lifecycle hooks (registration, login, settings) translate the -// Ory event into Segment HTTP Tracking API calls (identify and/or track). -// The handler returns 200 to Ory immediately, then talks to Segment in the -// background — Segment availability never blocks user flows. +// Three lifecycle actions (registration, login, settings) translate the Ory +// event into Segment HTTP Tracking API calls (identify and/or track). The +// actions set response.ignore: true, so Ory doesn't wait for this handler and +// Segment availability never blocks user flows. // // Authenticates to Segment with HTTP Basic auth: write key as username, // empty password. @@ -21,7 +21,8 @@ import express, { const PORT = Number(process.env.PORT) || 3000; const ORY_WEBHOOK_SECRET = process.env.ORY_WEBHOOK_SECRET; const SEGMENT_WRITE_KEY = process.env.SEGMENT_WRITE_KEY; -const SEGMENT_API_BASE = process.env.SEGMENT_API_BASE || "https://api.segment.io/v1"; +const SEGMENT_API_BASE = + process.env.SEGMENT_API_BASE || "https://api.segment.io/v1"; const LIBRARY_NAME = "ory-integrates/segment"; const LIBRARY_VERSION = "1.0.0"; @@ -37,15 +38,16 @@ interface OryWebhookBody { id?: string; traits?: { email?: string; - name?: { first?: string; last?: string }; + name?: { first?: string; last?: string } | null; }; - created_at?: string; + created_at?: string | null; }; flow?: { id?: string; type?: string; }; - request_headers?: Record; + client_ip?: string | null; + user_agent?: string | null; } interface SegmentContext { @@ -68,14 +70,9 @@ function verifyWebhookSecret(req: Request, res: Response, next: NextFunction) { next(); } -function messageId(): string { - return `ory-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - function clientContext(body: OryWebhookBody): SegmentContext { - const headers = body?.request_headers; - const ip = headers?.["x-forwarded-for"]?.[0]?.split(",")[0]?.trim(); - const userAgent = headers?.["user-agent"]?.[0]; + const ip = body?.client_ip; + const userAgent = body?.user_agent; return { library: { name: LIBRARY_NAME, version: LIBRARY_VERSION }, ...(ip && { ip }), @@ -90,39 +87,36 @@ function traitsFromIdentity( email: identity?.traits?.email, firstName: identity?.traits?.name?.first, lastName: identity?.traits?.name?.last, - createdAt: identity?.created_at, + createdAt: identity?.created_at ?? undefined, }; } async function callSegment(path: string, body: unknown): Promise { - try { - const res = await fetch(`${SEGMENT_API_BASE}${path}`, { - method: "POST", - headers: { "content-type": "application/json", authorization: SEGMENT_AUTH }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(5000), - }); - if (!res.ok) { - const detail = await res.text().catch(() => ""); - console.warn(`Segment ${path} ${res.status}: ${detail}`); - } - } catch (err) { - // Fire-and-forget: a Segment outage shouldn't propagate to Ory. - console.warn(`Segment ${path} request failed: ${(err as Error).message}`); - } + const res = await fetch(`${SEGMENT_API_BASE}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: SEGMENT_AUTH, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) + throw new Error(`Segment ${path} ${res.status}: ${await res.text()}`); } async function identify( userId: string, traits: Record, context: SegmentContext, + messageId: string, ): Promise { await callSegment("/identify", { type: "identify", userId, traits, timestamp: new Date().toISOString(), - messageId: messageId(), + messageId, context, }); } @@ -132,6 +126,7 @@ async function track( event: string, properties: Record, context: SegmentContext, + messageId: string, ): Promise { await callSegment("/track", { type: "track", @@ -139,7 +134,7 @@ async function track( event, properties, timestamp: new Date().toISOString(), - messageId: messageId(), + messageId, context, }); } @@ -148,60 +143,57 @@ app.get("/health", (_req, res) => { res.json({ ok: true }); }); -// Registration: identify with traits + track "User Registered" in parallel. -app.post( - "/segment/registration", - verifyWebhookSecret, - async (req: Request, res: Response) => { - res.status(200).json({}); +// Each Ory retry has the same trigger ID. Segment uses messageId to deduplicate +// successful calls if another call in the same delivery fails. +function lifecycleHandler(kind: "registration" | "login" | "settings") { + return async ( + req: Request, + res: Response, + ) => { const identity = req.body?.identity; - if (!identity?.id) return; - - const ctx = clientContext(req.body); - await Promise.all([ - identify(identity.id, traitsFromIdentity(identity), ctx), - track( - identity.id, - "User Registered", - { method: req.body?.flow?.type, flow_id: req.body?.flow?.id }, - ctx, - ), - ]); - console.log(`Segment: registered ${identity.id}`); - }, -); + if (!identity?.id) { + res.status(400).json({ error: "identity.id is required" }); + return; + } + const triggerId = + req.header("ory-webhook-trigger-id") ?? crypto.randomUUID(); + const context = clientContext(req.body); + try { + if (kind !== "login") { + await identify( + identity.id, + traitsFromIdentity(identity), + context, + `${triggerId}-identify`, + ); + } + if (kind !== "settings") { + await track( + identity.id, + kind === "registration" ? "User Registered" : "User Logged In", + { flow_type: req.body?.flow?.type, flow_id: req.body?.flow?.id }, + context, + `${triggerId}-track`, + ); + } + res.status(200).json({}); + } catch (error) { + console.warn(`Segment delivery failed: ${(error as Error).message}`); + res.status(502).json({ error: "delivery_failed" }); + } + }; +} -// Login: track "User Logged In". app.post( - "/segment/login", + "/segment/registration", verifyWebhookSecret, - async (req: Request, res: Response) => { - res.status(200).json({}); - const identity = req.body?.identity; - if (!identity?.id) return; - - await track( - identity.id, - "User Logged In", - { method: req.body?.flow?.type, flow_id: req.body?.flow?.id }, - clientContext(req.body), - ); - console.log(`Segment: tracked login for ${identity.id}`); - }, + lifecycleHandler("registration"), ); - -// Settings: refresh traits with identify. +app.post("/segment/login", verifyWebhookSecret, lifecycleHandler("login")); app.post( "/segment/settings", verifyWebhookSecret, - async (req: Request, res: Response) => { - res.status(200).json({}); - const identity = req.body?.identity; - if (!identity?.id) return; - - await identify(identity.id, traitsFromIdentity(identity), clientContext(req.body)); - console.log(`Segment: updated traits for ${identity.id}`); - }, + lifecycleHandler("settings"), ); app.listen(PORT, () => { diff --git a/compliance-audit/drata/README.md b/compliance-audit/drata/README.md index 5159fee..b054492 100644 --- a/compliance-audit/drata/README.md +++ b/compliance-audit/drata/README.md @@ -9,20 +9,21 @@ ## Setup outline -1. Create an **Ory API key** (Project-scoped) with read access to identities, OAuth2 clients, and configuration. -2. In Drata → **Connections** → **Custom Connector**, configure an HTTP poller to query the Ory admin API on a schedule. -3. Common evidence to pull: - - `GET /admin/identities` → user list, creation/deletion timestamps, MFA enrollment. - - `GET /admin/identities/{id}` → per-user roles, credentials, recovery method. +1. Create a **project API key** for the Ory Admin API (identities and OAuth2 clients). Ory Network has no read-only API key: a project API key has full admin access to the project's data, and it can't read the project configuration. Store it as a secret in Drata. +2. To collect configuration evidence, create a **workspace API key** and read the project with `GET https://api.console.ory.com/projects/`. A workspace API key can also change the configuration of the projects in the workspace. See [API keys](https://www.ory.com/docs/concepts/personal-access-token#workspace-api-keys-vs-project-api-keys). +3. In Drata → **Connections** → **Custom Connector**, configure an HTTP poller against these APIs on a schedule. +4. Common evidence to pull: + - `GET /admin/identities` → user list, lifecycle timestamps, JML controls. Returns credentials only with `include_credential`. + - `GET /admin/identities/{id}` → per-user roles, credential types, recovery method. - `GET /admin/clients` → OAuth2 clients (treat as service accounts). - - Ory project config → password policy, session lifespan, MFA requirements. -4. Map the returned fields to Drata's evidence types (Personnel, Access, MFA, Termination). + - `GET https://api.console.ory.com/projects/` (workspace API key) → password policy, session lifespan, MFA requirements. +5. Map the returned fields to Drata's evidence types (Personnel, Access, MFA, Termination). ## Notable evidence patterns -- **Joiner / mover / leaver**: identity create / update / deactivate timestamps in Ory map to JML controls. -- **Privileged access**: OAuth2 clients with admin scopes — Drata can flag any client whose scope grants admin API access. -- **MFA enforcement**: pull each identity's credentials array and flag any without a TOTP / WebAuthn entry where policy requires it. +- **Joiner / mover / leaver**: `created_at`, `updated_at`, `state`, and `state_changed_at` map directly to JML controls. A deactivated identity has `state: inactive`. +- **Privileged access**: inventory OAuth2 clients and the scopes they're allowed to request. +- **MFA enforcement**: credential types alone don't prove MFA enrollment: password identities can have an empty `webauthn` credential. Request `GET /admin/identities/{id}?include_credential=webauthn` and check that `credentials.webauthn.config.credentials` isn't empty; a `totp` credential exists only while TOTP is set up. Don't request `include_credential=password` or `include_credential=totp`, which return the password hash and the TOTP secret. ## Status diff --git a/compliance-audit/drata/registry.entry.yaml b/compliance-audit/drata/registry.entry.yaml index c289388..57eafe9 100644 --- a/compliance-audit/drata/registry.entry.yaml +++ b/compliance-audit/drata/registry.entry.yaml @@ -15,11 +15,11 @@ useCase: >- An Ory Network customer pursuing or maintaining SOC 2 / ISO 27001 / similar compliance wants Drata to auto-collect IAM evidence from Ory rather than running manual evidence-gathering each audit cycle. coreFunctionality: |- - - Drata Generic Custom Connector polls the Ory admin API on a schedule using a Project-scoped API key with read access. - - Common evidence pulls: GET /admin/identities (user list + lifecycle timestamps + MFA enrollment), GET /admin/identities/{id} (per-user roles / credentials / recovery), GET /admin/clients (OAuth2 clients as service accounts), Ory project config (password policy, session lifespan, MFA requirements). - - JML controls map to identity create / update / deactivate timestamps. - - Privileged access: OAuth2 clients with admin scopes can be flagged. - - MFA enforcement: flag identities without TOTP/WebAuthn credentials where policy requires it. + - Drata Generic Custom Connector polls the Ory admin API on a schedule with a project API key (full admin access; Ory Network has no read-only key) and reads the project configuration with a workspace API key. + - Common evidence pulls: GET /admin/identities (user list + lifecycle timestamps + MFA enrollment), GET /admin/identities/{id} (per-user roles / credentials / recovery), GET /admin/clients (OAuth2 clients as service accounts), GET https://api.console.ory.com/projects/ (password policy, session lifespan, MFA requirements). + - JML controls map to created_at, updated_at, state, and state_changed_at. + - Privileged access: inventory OAuth2 clients and the scopes they're allowed to request. + - MFA enforcement: check credentials.webauthn.config.credentials with include_credential=webauthn, or the presence of a totp credential; credential types alone don't prove enrollment. - Status: community/proposed — documented at ory.com/docs/integrates-with/compliance-audit/drata; implementation lives in Drata's custom-connector layer. oryMechanism: - standards diff --git a/compliance-audit/vanta/README.md b/compliance-audit/vanta/README.md index f2c98ae..0bacf6d 100644 --- a/compliance-audit/vanta/README.md +++ b/compliance-audit/vanta/README.md @@ -9,21 +9,22 @@ ## Setup outline -1. Create an **Ory API key** (Project-scoped) with read access to identities, OAuth2 clients, and configuration. -2. In Vanta → **Integrations** → **Add Custom Integration** (or via the Vanta API), configure an API poller against the Ory admin API on a schedule. -3. Common evidence to pull: - - `GET /admin/identities` → user list, creation/deletion timestamps, MFA enrollment. - - `GET /admin/identities/{id}` → per-user roles, credentials, recovery method. +1. Create a **project API key** for the Ory Admin API (identities and OAuth2 clients). Ory Network has no read-only API key: a project API key has full admin access to the project's data, and it can't read the project configuration. Store it as a secret in Vanta. +2. To collect configuration evidence, create a **workspace API key** and read the project with `GET https://api.console.ory.com/projects/`. A workspace API key can also change the configuration of the projects in the workspace. See [API keys](https://www.ory.com/docs/concepts/personal-access-token#workspace-api-keys-vs-project-api-keys). +3. In Vanta → **Integrations** → **Add Custom Integration** (or via the Vanta API), configure an API poller against these APIs on a schedule. +4. Common evidence to pull: + - `GET /admin/identities` → user list, lifecycle timestamps. Returns credentials only with `include_credential`. + - `GET /admin/identities/{id}` → per-user roles, credential types, recovery method. - `GET /admin/clients` → OAuth2 clients (treat as service accounts). - - Ory project config → password policy, session lifespan, MFA requirements. -4. Map the returned fields to Vanta's compliance Tests (Personnel, Access Reviews, MFA, Termination, Password Policy). + - `GET https://api.console.ory.com/projects/` (workspace API key) → password policy, session lifespan, MFA requirements. +5. Map the returned fields to Vanta's compliance Tests (Personnel, Access Reviews, MFA, Termination, Password Policy). ## Notable evidence patterns -- **Access reviews**: pull the full identity list + their last-active timestamp; Vanta can flag dormant accounts. -- **Termination controls**: deactivation timestamps in Ory close the loop on offboarding evidence. -- **MFA enforcement**: pull each identity's credentials array; Vanta flags identities without TOTP / WebAuthn where policy requires it. -- **Password policy**: pull project config; Vanta validates against the configured minimum complexity rules. +- **Access reviews**: pull the full identity list; for last activity, list each identity's sessions (`GET /admin/identities/{id}/sessions`). Vanta can flag dormant accounts. +- **Termination controls**: `state: inactive` and `state_changed_at` close the loop on offboarding evidence. +- **MFA enforcement**: credential types alone don't prove MFA enrollment: password identities can have an empty `webauthn` credential. Request `GET /admin/identities/{id}?include_credential=webauthn` and check that `credentials.webauthn.config.credentials` isn't empty; a `totp` credential exists only while TOTP is set up. Don't request `include_credential=password` or `include_credential=totp`, which return the password hash and the TOTP secret. +- **Password policy**: pull the project configuration with the workspace API key; Vanta validates it against the configured minimum complexity rules. ## Status diff --git a/compliance-audit/vanta/registry.entry.yaml b/compliance-audit/vanta/registry.entry.yaml index 754a19d..0b32863 100644 --- a/compliance-audit/vanta/registry.entry.yaml +++ b/compliance-audit/vanta/registry.entry.yaml @@ -15,11 +15,11 @@ useCase: >- An Ory Network customer pursuing or maintaining SOC 2 / ISO 27001 / similar compliance wants Vanta to auto-collect IAM evidence from Ory rather than running manual evidence-gathering each audit cycle. coreFunctionality: |- - - Vanta Custom Integration polls the Ory admin API on a schedule using a Project-scoped API key with read access. - - Common evidence pulls: GET /admin/identities (user list + lifecycle + MFA enrollment), GET /admin/identities/{id} (roles / credentials / recovery), GET /admin/clients (OAuth2 clients as service accounts), Ory project config (password policy, session lifespan, MFA). - - Access reviews: dormant-account detection via identity list + last-active timestamps. - - Termination controls: deactivation timestamps close the loop on offboarding evidence. - - MFA enforcement: flag identities without TOTP/WebAuthn where policy requires it. + - Vanta Custom Integration polls the Ory admin API on a schedule with a project API key (full admin access; Ory Network has no read-only key) and reads the project configuration with a workspace API key. + - Common evidence pulls: GET /admin/identities (user list + lifecycle + MFA enrollment), GET /admin/identities/{id} (roles / credentials / recovery), GET /admin/clients (OAuth2 clients as service accounts), GET https://api.console.ory.com/projects/ (password policy, session lifespan, MFA). + - Access reviews: dormant-account detection via the identity list and each identity's sessions. + - Termination controls: state: inactive and state_changed_at close the loop on offboarding evidence. + - MFA enforcement: check credentials.webauthn.config.credentials with include_credential=webauthn, or the presence of a totp credential; credential types alone don't prove enrollment. - Password policy: validate against Ory project config minimum-complexity rules. - Status: community/proposed — documented at ory.com/docs/integrates-with/compliance-audit/vanta; implementation lives in Vanta's custom-integration layer. oryMechanism: diff --git a/consent-privacy/didomi/README.md b/consent-privacy/didomi/README.md index df375b0..cec3278 100644 --- a/consent-privacy/didomi/README.md +++ b/consent-privacy/didomi/README.md @@ -12,8 +12,8 @@ 1. In Didomi → **Consent Notice** → **Webhooks**, configure a webhook to fire on consent events with HMAC-signed payloads. 2. Deploy a small service that: - Verifies the Didomi HMAC signature. - - Resolves the Ory identity by Didomi's `user_id` (typically matched against Ory's identity trait carrying the same id, or `metadata_public.didomi_user_id`). - - PATCHes `metadata_public.consent.didomi` on the identity with the consent record (purposes consented, vendors, timestamp, consent string). + - Resolves the Ory identity from the Didomi user ID with `GET /admin/identities/by/external/{externalID}`. For this, set the identity's `external_id` to the Didomi user ID when you create the identity, or later with a JSON Patch on `/external_id`. The Admin API can't look up identities by metadata. + - Reads the identity's current `metadata_public`. In one JSON Patch request (`PATCH /admin/identities/{id}`), initializes `/metadata_public` to `{}` only if it is null or absent, then initializes `/metadata_public/consent` to `{}` only if it is null or absent, and finally adds `/metadata_public/consent/didomi` with the consent record (purposes consented, vendors, timestamp, consent string). Preserve existing metadata and other consent providers' values. Serialize updates for the same identity so concurrent events don't overwrite each other's initialization. 3. For **DSAR fulfillment** (GDPR Article 15 / 17 access + erasure): - Didomi forwards the request to your handler. - Handler queries `GET /admin/identities/{id}` and returns / deletes per Article requirements. diff --git a/consent-privacy/didomi/registry.entry.yaml b/consent-privacy/didomi/registry.entry.yaml index 01bc351..45a34e3 100644 --- a/consent-privacy/didomi/registry.entry.yaml +++ b/consent-privacy/didomi/registry.entry.yaml @@ -18,7 +18,7 @@ useCase: >- coreFunctionality: |- - Didomi → webhook → Ory admin API pattern. No first-party connector; documented at ory.com/docs/integrates-with/consent-privacy/didomi. - Didomi webhook configured under Consent Notice → Webhooks; fires on consent events with HMAC-signed payloads. - - Handler verifies HMAC, resolves the Ory identity by Didomi user_id (matched to an identity trait or metadata_public.didomi_user_id), PATCHes metadata_public.consent.didomi with the consent record. + - Handler verifies HMAC, resolves the Ory identity from the Didomi user ID stored as the identity's external_id (GET /admin/identities/by/external/{externalID}), and JSON-Patches metadata_public.consent.didomi with the consent record, initializing missing parents and preserving other metadata. - DSAR fulfillment (GDPR Article 15 access / 17 erasure): GET /admin/identities/{id} for access; DELETE /admin/identities/{id} for erasure. 30-day statutory window — design for reliable processing. - Consent strings (TCF v2 + Didomi-specific) stored in metadata_public only — not credentials. - Status: community/proposed — implementation lives in the customer's webhook handler. diff --git a/consent-privacy/onetrust/README.md b/consent-privacy/onetrust/README.md index 37dd9e5..f297201 100644 --- a/consent-privacy/onetrust/README.md +++ b/consent-privacy/onetrust/README.md @@ -2,7 +2,7 @@ > **Maintained by:** Community contributors -[OneTrust](https://onetrust.com) is a privacy, security, and governance platform for managing consent, fulfilling Data Subject Requests (DSRs), and maintaining compliance with GDPR, CCPA, LGPD, and PIPA. This integration writes a consent receipt to OneTrust on every registration and exposes a DSR callback endpoint that lets OneTrust's Privacy Rights Automation workflow delete, export, or rectify identities in Ory. +[OneTrust](https://onetrust.com) is a privacy, security, and governance platform for managing consent, fulfilling Data Subject Requests (DSRs), and maintaining compliance with GDPR, CCPA, LGPD, and PIPA. This integration stores consent choices on the Ory identity at registration, writes a consent receipt keyed by the Ory identity ID to OneTrust, and exposes a DSR callback endpoint that lets OneTrust's Privacy Rights Automation workflow delete, export, or rectify identities in Ory. **Type:** webhook (Ory Action calls a handler during a flow) **Docs page:** [ory.com/docs/integrates-with/consent-privacy/onetrust](https://www.ory.com/docs/integrates-with/consent-privacy/onetrust) @@ -13,11 +13,12 @@ A regulated B2C product needs a defensible audit trail of consent (marketing, an ## How it works -1. The browser collects consent choices and submits them via Ory's `transient_payload.consent` (e.g. `{"marketing":true,"analytics":false}`). -2. Ory fires the sync post-registration Action webhook. The handler verifies the shared secret. -3. The handler immediately returns `200` with `identity.metadata_public.consent` set, so Ory records the consent on the new identity even if OneTrust is unreachable. -4. After the response is flushed, the handler exchanges its OneTrust OAuth2 client credentials for an access token (cached in memory) and POSTs a consent receipt to `POST /api/consentmanager/v2/consent-receipts`, mapping each consent category key to the configured OneTrust Purpose ID. -5. Separately, when a DSR is approved in OneTrust, OneTrust's Privacy Rights workflow calls `POST /onetrust/dsr` on this handler with `X-OneTrust-Secret`. The handler looks up the identity by email and runs the requested action (`DELETE`/`ERASURE`, `EXPORT`/`ACCESS`, or `RECTIFICATION`) against Ory's Admin API. +1. The browser collects consent choices and submits them in the registration flow's `transient_payload.consent`, for example `{"marketing":true,"analytics":false}`. +2. The first Ory Action (`response.parse: true`) calls `POST /onetrust/registration` before Ory saves the identity. The handler verifies the shared secret and returns `identity.metadata_public.consent`, merged with the metadata the request carried, so Ory stores the choices on the new identity. The identity has no ID yet (`00000000-0000-0000-0000-000000000000`), so this route doesn't call OneTrust. Invalid or missing consent returns `200` with no changes, so registration is never blocked. +3. The second Ory Action (`response.ignore: true`) calls `POST /onetrust/receipt` after Ory saves the identity. The handler exchanges its OneTrust OAuth2 client credentials for an access token (cached in memory) and POSTs a consent receipt to `POST /api/consentmanager/v2/consent-receipts`, keyed by the Ory identity ID and mapping each consent category to the configured OneTrust Purpose ID. A failed OneTrust call returns `502`, which Ory retries up to three attempts in total. +4. When a DSR is approved in OneTrust, the Privacy Rights workflow calls `POST /onetrust/dsr` with `X-OneTrust-Secret` and the stored Ory identity ID in `oryIdentityId` (or `dataSubject.oryIdentityId`). The handler reads the identity with `GET /admin/identities/{id}` and runs the requested action (`DELETE`/`ERASURE`, `EXPORT`/`ACCESS`, or `RECTIFICATION`) against Ory's Admin API. Rectification applies `dataSubject.corrections` as a JSON Patch on the identity's traits. + +The DSR route doesn't look identities up by email: an email trait isn't necessarily a credential identifier, for example for social sign-in. Use the Ory identity ID recorded on the consent receipt. For older receipts without an identity ID, establish a verified mapping before processing the request. ## Prerequisites @@ -40,17 +41,18 @@ npm start The server listens on the port specified in `.env` (default 3000) and exposes: - `GET /health` — readiness check. -- `POST /onetrust/registration` — Ory sync post-registration Action target. +- `POST /onetrust/registration` — Ory Action target (`response.parse: true`), returns the consent metadata. +- `POST /onetrust/receipt` — Ory Action target (`response.ignore: true`), records the OneTrust consent receipt. - `POST /onetrust/dsr` — OneTrust DSR callback (gated by `X-OneTrust-Secret`). ## Configure Ory -1. In the Ory Console, configure the Action hook using the snippet in [`ory-actions.yaml`](ory-actions.yaml). -2. The body template is [`jsonnet/registration.jsonnet`](jsonnet/registration.jsonnet). +1. Add both Action hooks from [`ory-actions.yaml`](ory-actions.yaml) to `selfservice.flows.registration.after.password.hooks`, and to the list of every other sign-up method you enable, such as `oidc`, `code`, or `passkey`. Keep `session` last in each list. Don't use the flow-level `registration.after.hooks` list: Ory runs it only for methods without hooks of their own. See [Attach `after` actions to authentication methods](https://www.ory.com/docs/kratos/hooks/configure-hooks#attach-after-actions-to-authentication-methods). +2. Both actions use the body template [`jsonnet/registration.jsonnet`](jsonnet/registration.jsonnet). Encode it with `base64 < jsonnet/registration.jsonnet | tr -d '\n'` and set `body: base64://`. Ory Network doesn't accept `file://` URLs. 3. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value declared in the hook config. -4. In OneTrust's Privacy Rights Automation workflow, add a webhook step pointing at `https://your-handler.example.com/onetrust/dsr` with `X-OneTrust-Secret: `. +4. In OneTrust's Privacy Rights Automation workflow, add a webhook step pointing at `https://your-handler.example.com/onetrust/dsr` with `X-OneTrust-Secret: ` and a JSON body that contains `requestType` and `oryIdentityId`. -Identity-schema consent fields, OneTrust Purpose ID mapping, and DSR workflow wiring: see the [docs page](https://ory.com/docs/integrates-with/consent-privacy/onetrust). +Identity-schema consent fields, OneTrust Purpose ID mapping, and DSR workflow wiring: see the [docs page](https://www.ory.com/docs/integrates-with/consent-privacy/onetrust). ## Troubleshooting @@ -59,7 +61,9 @@ Identity-schema consent fields, OneTrust Purpose ID mapping, and DSR workflow wi - **`OneTrust OAuth 401`** in handler logs — `ONETRUST_CLIENT_ID`/`ONETRUST_CLIENT_SECRET` are wrong, or the credentials don't have the `consent` scope. - **`OneTrust OAuth 400 invalid_grant`** — usually a `grant_type` mismatch; the client must be configured for `client_credentials` in OneTrust admin. - **No purposes appear on the consent receipt** — `ONETRUST_PURPOSE_*` env vars aren't set for the consent keys the client is sending. The handler skips unmapped categories silently. -- **DSR returns `identity_not_found`** — the email in OneTrust's request doesn't match the Ory credential identifier. +- **DSR returns `oryIdentityId required`** — the OneTrust workflow didn't send the Ory identity ID. +- **DSR returns `identity_not_found`** — no identity exists with that ID, for example because it was already deleted. +- **Registration fails with a webhook error** — the body template failed or the handler returned a `4xx` without the `messages` shape. Check the handler logs and the Ory project logs. ## License diff --git a/consent-privacy/onetrust/jsonnet/registration.jsonnet b/consent-privacy/onetrust/jsonnet/registration.jsonnet index 4ea9eff..9022ed7 100644 --- a/consent-privacy/onetrust/jsonnet/registration.jsonnet +++ b/consent-privacy/onetrust/jsonnet/registration.jsonnet @@ -1,19 +1,20 @@ -// Body for the sync post-registration Ory Action that calls the OneTrust -// webhook handler. The client must pass consent choices via transient_payload: +// Body for both OneTrust registration actions. The client passes consent +// choices in the registration flow's transient_payload: // // { "transient_payload": { "consent": { "marketing": true, "analytics": false } } } // -// The handler returns updated metadata_public.consent so Ory records the -// choices on the new identity even if OneTrust is unreachable. -function(ctx) { - identity: { - id: ctx.identity.id, - traits: ctx.identity.traits, - metadata_public: ctx.identity.metadata_public, - }, - flow: { - id: ctx.flow.id, - type: ctx.flow.type, - transient_payload: ctx.flow.transient_payload, - }, -} +// Jsonnet fails on a missing field, and a failing template fails the user's +// registration for the response.parse action, so every optional field is read +// with std.get. +function(ctx) + local payload = std.get(ctx.flow, 'transient_payload', null); + local consent = if payload == null then null else std.get(payload, 'consent', null); + local metadataPublic = std.get(ctx.identity, 'metadata_public', null); + { + identity: { + id: ctx.identity.id, + email: std.get(ctx.identity.traits, 'email', null), + metadata_public: if metadataPublic == null then {} else metadataPublic, + }, + consent: if std.isObject(consent) then consent else null, + } diff --git a/consent-privacy/onetrust/ory-actions.yaml b/consent-privacy/onetrust/ory-actions.yaml index 38f0c5b..44b5655 100644 --- a/consent-privacy/onetrust/ory-actions.yaml +++ b/consent-privacy/onetrust/ory-actions.yaml @@ -1,31 +1,56 @@ -# OneTrust consent capture hook. +# OneTrust consent capture hooks. # -# Sync hook (response.parse: true, can_interrupt: false). The handler returns -# updated identity.metadata_public.consent so Ory records the user's choices -# immediately on the new identity. The OneTrust receipt call happens in the -# background after the response is flushed; if OneTrust is unreachable, the -# consent record in Ory is still preserved. +# The first action (response.parse: true) runs before the identity is saved +# and returns metadata_public.consent, so Ory stores the choices on the new +# identity. The identity has no ID yet at that point. The second action +# (response.ignore: true) runs after the identity is saved and records the +# OneTrust consent receipt with the identity ID. # -# The client must pass consent choices via transient_payload: -# { "transient_payload": { "consent": { "marketing": true, "analytics": false } } } +# Encode the body template with `base64 < jsonnet/registration.jsonnet | tr -d '\n'`. +# Ory Network doesn't accept file:// URLs. +# +# The password list is aliased into the oidc, code, passkey, and webauthn +# lists, so every sign-up method runs both actions with `session` last. Alias +# it into any other method you enable, such as saml, the same way. selfservice: flows: registration: after: - hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/onetrust/registration - method: POST - body: file://./jsonnet/registration.jsonnet - can_interrupt: false - response: - ignore: false - parse: true - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/onetrust/registration + method: POST + body: base64:// + response: + parse: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/onetrust/receipt + method: POST + body: base64:// + response: + ignore: true + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks diff --git a/consent-privacy/onetrust/registry.entry.yaml b/consent-privacy/onetrust/registry.entry.yaml index 29b698f..731cc1c 100644 --- a/consent-privacy/onetrust/registry.entry.yaml +++ b/consent-privacy/onetrust/registry.entry.yaml @@ -9,7 +9,7 @@ type: webhook maintainedBy: Community contributors description: >- OneTrust is a privacy, security, and governance platform for managing consent, fulfilling Data Subject Requests, and - maintaining compliance with GDPR, CCPA, LGPD, and PIPA. This integration writes a consent receipt to OneTrust on every + maintaining compliance with GDPR, CCPA, LGPD, and PIPA. This integration stores consent choices on the Ory identity and writes a consent receipt keyed by the Ory identity ID to OneTrust on every registration and exposes a DSR callback endpoint that lets OneTrust's Privacy Rights Automation workflow delete, export, or rectify identities in Ory. useCase: >- @@ -17,11 +17,12 @@ useCase: >- signup, plus a single button in the OneTrust UI that fulfills a deletion or export request across all downstream systems including the identity store. coreFunctionality: |- - - Captures consent choices from transient_payload.consent on the sync post-registration webhook, returns them in metadata_public.consent immediately so Ory records the user's choices even if OneTrust is unreachable. - - Exchanges OAuth2 client credentials for a OneTrust access token (cached in memory until 30 s before expiry) and POSTs a consent receipt to the Consent Manager API mapping each category to a configured Purpose ID. - - Exposes a separate /onetrust/dsr endpoint gated by ONETRUST_DSR_SECRET that fulfills DELETE/ERASURE, EXPORT/ACCESS, and RECTIFICATION DSRs against Ory's Admin API. - - Distinct shared secrets for the two surfaces so a leak of one credential can't be used to call the other. - - Skips unmapped consent categories silently so partial Purpose ID configuration doesn't break registration. + - A response.parse post-registration webhook returns the consent choices from transient_payload.consent in metadata_public.consent, merged with the existing metadata, so Ory stores them on the new identity. It fails open and never blocks registration. + - A response.ignore post-registration webhook runs after the identity is saved and POSTs a OneTrust consent receipt keyed by the Ory identity ID, mapping each category to a configured Purpose ID. + - Exchanges OAuth2 client credentials for a OneTrust access token (cached in memory until 30 s before expiry). + - Exposes a separate /onetrust/dsr endpoint gated by ONETRUST_DSR_SECRET that fulfills DELETE/ERASURE, EXPORT/ACCESS, and RECTIFICATION DSRs for the stored Ory identity ID against Ory's Admin API. + - Distinct shared secrets for the Ory and OneTrust surfaces. + - Skips unmapped consent categories so partial Purpose ID configuration doesn't break registration. oryMechanism: - actions protocol: diff --git a/consent-privacy/onetrust/webhook/server.ts b/consent-privacy/onetrust/webhook/server.ts index 4b1b484..30f6fd8 100644 --- a/consent-privacy/onetrust/webhook/server.ts +++ b/consent-privacy/onetrust/webhook/server.ts @@ -2,21 +2,21 @@ // // OneTrust <> Ory Actions webhook handler. // -// Two surfaces: +// Three surfaces: // -// 1. Post-registration (Ory -> handler) — sync hook that captures the -// user's consent choices from transient_payload, writes them to -// identity.metadata_public.consent so Ory holds the record of truth, -// and records a consent receipt in OneTrust via the Consent Manager -// API. The Ory call is synchronous (response.parse: true) so the -// consent metadata lands on the new identity; the OneTrust receipt -// call runs after the response goes back so OneTrust availability -// never blocks registration. +// 1. POST /onetrust/registration (Ory -> handler, response.parse: true). +// Runs before Ory saves the identity and returns the user's consent +// choices in identity.metadata_public.consent. The identity has no ID +// yet, so this route doesn't call OneTrust. // -// 2. DSR callback (OneTrust -> handler) — async webhook from OneTrust's -// Privacy Rights Automation workflow. Authenticates with a separate -// shared secret (X-OneTrust-Secret), then dispatches DELETE/ERASURE, -// EXPORT/ACCESS, or RECTIFICATION against Ory's Admin API. +// 2. POST /onetrust/receipt (Ory -> handler, response.ignore: true). Runs +// after Ory saves the identity and records a OneTrust consent receipt +// keyed by the Ory identity ID. +// +// 3. POST /onetrust/dsr (OneTrust -> handler). Called by OneTrust's Privacy +// Rights Automation workflow with a separate shared secret +// (X-OneTrust-Secret). Dispatches DELETE/ERASURE, EXPORT/ACCESS, or +// RECTIFICATION against Ory's Admin API for the stored Ory identity ID. // // OneTrust authenticates with OAuth2 client_credentials. The handler caches // the access token in memory until 30s before its declared expiry and then @@ -66,28 +66,34 @@ interface OryIdentity { id: string; schema_id?: string; state?: string; - traits?: { email?: string; [trait: string]: unknown }; - metadata_public?: Record; - metadata_admin?: Record; + traits?: Record; + metadata_public?: Record | null; + created_at?: string; + updated_at?: string; } +// Shape produced by jsonnet/registration.jsonnet. interface OryRegistrationWebhookBody { - identity: OryIdentity; - flow: { - transient_payload?: { consent?: Record }; + identity?: { + id?: string; + email?: string | null; + metadata_public?: Record; }; + consent?: Record | null; } interface OneTrustDsrBody { requestType?: string; + oryIdentityId?: string; dataSubject?: { - email?: string; + oryIdentityId?: string; corrections?: Record; }; - identifier?: string; - email?: string; } +const NIL_UUID = "00000000-0000-0000-0000-000000000000"; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const app = express(); app.use(express.json()); @@ -116,6 +122,14 @@ function verifyOneTrustDsrSecret(req: Request, res: Response, next: NextFunction next(); } +function consentChoices(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const choices = Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean"), + ); + return Object.keys(choices).length > 0 ? choices : null; +} + interface CachedToken { token: string; expiresAt: number; @@ -149,7 +163,8 @@ async function getOneTrustAccessToken(): Promise { } async function recordConsentReceipt( - identity: OryIdentity, + identityId: string, + email: string | undefined, consent: Record, ): Promise { const purposes = Object.entries(consent) @@ -159,19 +174,21 @@ async function recordConsentReceipt( Name: key, TransactionType: granted ? "CONFIRMED" : "OPT_OUT", })); - if (purposes.length === 0) return; // nothing mapped, nothing to send + if (purposes.length === 0) return; const token = await getOneTrustAccessToken(); const receipt = { - identifier: identity.traits?.email, - identifierType: "email", + identifier: identityId, requestInformation: { language: "en-us", collectionPoint: "registration", collectionMethod: "web_form", }, purposes, - dsDataElements: [{ Name: "ory_identity_id", Value: identity.id }], + dsDataElements: [ + { Name: "ory_identity_id", Value: identityId }, + ...(email ? [{ Name: "email", Value: email }] : []), + ], consentDate: new Date().toISOString(), }; @@ -194,70 +211,86 @@ app.get("/health", (_req, res) => { res.json({ ok: true }); }); -// Sync post-registration. Consent goes into metadata_public IMMEDIATELY via the -// response so Ory holds it even if OneTrust is unreachable; OneTrust is then -// called in the background (after the response is flushed). +// Ory replaces metadata_public as a whole, so the response carries the +// existing metadata alongside the new consent record. Any input problem +// returns 200 with no changes so registration is never blocked. app.post( "/onetrust/registration", verifyOryWebhookSecret, - async (req: Request, res: Response) => { - const identity = req.body?.identity; - const consent = req.body?.flow?.transient_payload?.consent; - - if (consent && typeof consent === "object") { - res.status(200).json({ - identity: { - metadata_public: { - consent: { preferences: consent, recorded_at: new Date().toISOString() }, - }, + (req: Request, res: Response) => { + const consent = consentChoices(req.body?.consent); + if (!consent) { + res.status(200).json({}); + return; + } + const existing = req.body?.identity?.metadata_public; + const metadataPublic = + existing && typeof existing === "object" && !Array.isArray(existing) ? existing : {}; + res.status(200).json({ + identity: { + metadata_public: { + ...metadataPublic, + consent: { preferences: consent, recorded_at: new Date().toISOString() }, }, - }); - } else { + }, + }); + }, +); + +// Ory ignores this response but retries 429 and 5xx responses other than 501, +// so a failed OneTrust call returns 502. +app.post( + "/onetrust/receipt", + verifyOryWebhookSecret, + async (req: Request, res: Response) => { + const identityId = req.body?.identity?.id; + const consent = consentChoices(req.body?.consent); + if (!consent || !identityId || identityId === NIL_UUID) { res.status(200).json({}); + return; } - if (consent && identity?.id && identity.traits?.email) { - try { - await recordConsentReceipt(identity, consent); - console.log(`OneTrust: receipt recorded for ${identity.id}`); - } catch (err) { - console.warn(`OneTrust receipt failed for ${identity.id}: ${(err as Error).message}`); - } + try { + await recordConsentReceipt(identityId, req.body?.identity?.email ?? undefined, consent); + console.log(`OneTrust: receipt recorded for ${identityId}`); + res.status(200).json({}); + } catch (err) { + console.warn(`OneTrust receipt failed for ${identityId}: ${(err as Error).message}`); + res.status(502).json({ error: "receipt_failed" }); } }, ); -async function findIdentityByEmail(email: string): Promise { - const res = await fetch( - `${ORY_SDK_URL}/admin/identities?credentials_identifier=${encodeURIComponent(email)}`, - { - headers: { authorization: `Bearer ${ORY_ADMIN_API_KEY}` }, - signal: AbortSignal.timeout(5000), - }, - ); +async function getIdentity(id: string): Promise { + const res = await fetch(`${ORY_SDK_URL}/admin/identities/${id}`, { + headers: { authorization: `Bearer ${ORY_ADMIN_API_KEY}` }, + signal: AbortSignal.timeout(5000), + }); + if (res.status === 404) return null; if (!res.ok) throw new Error(`Ory lookup ${res.status}`); - const arr = (await res.json()) as OryIdentity[]; - return Array.isArray(arr) && arr.length > 0 ? (arr[0] ?? null) : null; + return (await res.json()) as OryIdentity; +} + +function escapeJsonPointer(segment: string): string { + return segment.replace(/~/g, "~0").replace(/\//g, "~1"); } // DSR callback from OneTrust's Privacy Rights Automation workflow. // requestType: DELETE | ERASURE | EXPORT | ACCESS | RECTIFICATION -// The handler runs the action against Ory's Admin API and returns a status to -// OneTrust. OneTrust treats 2xx as completed and 4xx/5xx as failed. +// OneTrust treats 2xx as completed and 4xx/5xx as failed. app.post( "/onetrust/dsr", verifyOneTrustDsrSecret, async (req: Request, res: Response) => { const requestType = String(req.body?.requestType ?? "").toUpperCase(); - const email = - req.body?.dataSubject?.email ?? req.body?.identifier ?? req.body?.email; - if (!email) { - res.status(400).json({ error: "email required" }); + const identityId = req.body?.oryIdentityId ?? req.body?.dataSubject?.oryIdentityId; + if (!identityId || !UUID_PATTERN.test(identityId)) { + res.status(400).json({ error: "oryIdentityId required" }); return; } try { - const identity = await findIdentityByEmail(email); + const identity = await getIdentity(identityId); if (!identity) { res.status(404).json({ error: "identity_not_found" }); return; @@ -274,7 +307,7 @@ app.post( headers, signal: AbortSignal.timeout(5000), }); - if (!del.ok) throw new Error(`Ory delete ${del.status}`); + if (!del.ok && del.status !== 404) throw new Error(`Ory delete ${del.status}`); console.log(`OneTrust DSR: deleted ${identity.id}`); res.json({ status: "completed", action: "deleted" }); return; @@ -289,6 +322,8 @@ app.post( traits: identity.traits, metadata_public: identity.metadata_public, state: identity.state, + created_at: identity.created_at, + updated_at: identity.updated_at, }, }); return; @@ -296,23 +331,22 @@ app.post( if (requestType === "RECTIFICATION") { const corrections = req.body?.dataSubject?.corrections; - if (!corrections || typeof corrections !== "object") { + if (!corrections || typeof corrections !== "object" || Object.keys(corrections).length === 0) { res.status(400).json({ error: "corrections required" }); return; } - const put = await fetch(`${ORY_SDK_URL}/admin/identities/${identity.id}`, { - method: "PUT", + const patch = Object.entries(corrections).map(([key, value]) => ({ + op: "add", + path: `/traits/${escapeJsonPointer(key)}`, + value, + })); + const upd = await fetch(`${ORY_SDK_URL}/admin/identities/${identity.id}`, { + method: "PATCH", headers, - body: JSON.stringify({ - schema_id: identity.schema_id, - traits: { ...(identity.traits ?? {}), ...corrections }, - state: identity.state, - metadata_public: identity.metadata_public, - metadata_admin: identity.metadata_admin, - }), + body: JSON.stringify(patch), signal: AbortSignal.timeout(5000), }); - if (!put.ok) throw new Error(`Ory put ${put.status}`); + if (!upd.ok) throw new Error(`Ory patch ${upd.status}`); console.log(`OneTrust DSR: rectified ${identity.id}`); res.json({ status: "completed", action: "rectified" }); return; @@ -321,7 +355,7 @@ app.post( res.status(400).json({ error: `unsupported requestType: ${requestType}` }); } catch (err) { console.error(`OneTrust DSR failed: ${(err as Error).message}`); - res.status(500).json({ error: (err as Error).message }); + res.status(500).json({ error: "dsr_failed" }); } }, ); diff --git a/consent-privacy/osano/README.md b/consent-privacy/osano/README.md index 3ace59c..520c17a 100644 --- a/consent-privacy/osano/README.md +++ b/consent-privacy/osano/README.md @@ -12,8 +12,8 @@ 1. In Osano → **Subject Rights** → **Integrations**, configure a webhook for consent and DSAR events. 2. Deploy a small service that: - Verifies Osano's webhook signature. - - Resolves the Ory identity by Osano's `user_id` (matched to an Ory identity trait or `metadata_public.osano_user_id`). - - PATCHes `metadata_public.consent.osano` on the identity with the consent record (purposes, timestamp, jurisdiction). + - Resolves the Ory identity from the Osano user ID with `GET /admin/identities/by/external/{externalID}`. For this, set the identity's `external_id` to the Osano user ID when you create the identity, or later with a JSON Patch on `/external_id`. The Admin API can't look up identities by metadata. + - Reads the identity's current `metadata_public`. In one JSON Patch request (`PATCH /admin/identities/{id}`), initializes `/metadata_public` to `{}` only if it is null or absent, then initializes `/metadata_public/consent` to `{}` only if it is null or absent, and finally adds `/metadata_public/consent/osano` with the consent record (purposes, timestamp, jurisdiction). Preserve existing metadata and other consent providers' values. Serialize updates for the same identity so concurrent events don't overwrite each other's initialization. 3. For **DSAR fulfillment** (GDPR Article 15 / 17 / 20 — access, erasure, portability): - Osano forwards the request to your handler. - Handler queries `GET /admin/identities/{id}` (access) or `DELETE /admin/identities/{id}` (erasure). diff --git a/consent-privacy/osano/registry.entry.yaml b/consent-privacy/osano/registry.entry.yaml index d683e14..f5e46a1 100644 --- a/consent-privacy/osano/registry.entry.yaml +++ b/consent-privacy/osano/registry.entry.yaml @@ -19,7 +19,7 @@ useCase: >- coreFunctionality: |- - Osano → webhook → Ory admin API pattern. No first-party connector; documented at ory.com/docs/integrates-with/consent-privacy/osano. - Osano webhook configured under Subject Rights → Integrations; fires on consent and DSAR events with signed payloads. - - Handler verifies signature, resolves the Ory identity by Osano user_id (matched to an identity trait or metadata_public.osano_user_id), PATCHes metadata_public.consent.osano with the consent record (purposes, timestamp, jurisdiction). + - Handler verifies signature, resolves the Ory identity from the Osano user ID stored as the identity's external_id (GET /admin/identities/by/external/{externalID}), and JSON-Patches metadata_public.consent.osano with the consent record (purposes, timestamp, jurisdiction). - DSAR fulfillment: GET /admin/identities/{id} (Article 15 access), DELETE /admin/identities/{id} (Article 17 erasure), traits export (Article 20 portability). - Time-bounded responses — 30 days GDPR / 45 days CCPA; design for reliable processing. - Status: community/proposed — implementation lives in the customer's webhook handler. diff --git a/crm/hubspot/README.md b/crm/hubspot/README.md index a310512..a459db6 100644 --- a/crm/hubspot/README.md +++ b/crm/hubspot/README.md @@ -17,7 +17,7 @@ A sales/marketing team running HubSpot wants every Ory-managed sign-up to land i 2. Ory fires the Action webhook to this handler. The handler verifies the shared secret. 3. The handler POSTs `https://api.hubapi.com/crm/v3/objects/contacts` with `email`, `firstname`, `lastname`, `ory_identity_id`. 4. If HubSpot returns `409` because the contact already exists, the handler parses the existing ID out of HubSpot's error message and PATCHes the same properties onto that contact — idempotent upsert. -5. The handler returns `200` to Ory. +5. The handler returns `200`. Ory doesn't wait for the response, but it retries a `429` or `5xx` response other than `501`, for up to three attempts in total. ## Prerequisites @@ -43,9 +43,14 @@ The server listens on the port specified in `.env` (default 3000) and exposes: ## Configure Ory -1. Configure the Action hook using the snippet in [`ory-actions.yaml`](ory-actions.yaml) — register on `after:registration` and (optionally) `after:settings`. -2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet). -3. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value in the hook config. +1. Configure the Action hooks using the snippet in [`ory-actions.yaml`](ory-actions.yaml). Attach them to the method-specific lists, not to the flow-level `after.hooks` lists: + - Registration: `selfservice.flows.registration.after..hooks` for each sign-up method you enable, such as `password` and `oidc`. On Ory Network, the `password` list contains `session`; keep it last. + - Profile updates (optional): `selfservice.flows.settings.after.profile.hooks`. + + See [Attach `after` actions to authentication methods](https://www.ory.com/docs/kratos/hooks/configure-hooks#attach-after-actions-to-authentication-methods). +2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet). Ory Network doesn't accept `file://` bodies, so set `body` to `base64://` followed by the Base64-encoded template. +3. Keep `response.ignore: true`. Ory then sends the request in the background, and a HubSpot error never fails the user's registration or profile update. +4. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value in the hook config. Private app provisioning, custom-property setup, and HubSpot workflow patterns: see the [docs page](https://ory.com/docs/integrates-with/crm/hubspot). diff --git a/crm/hubspot/jsonnet/identity.jsonnet b/crm/hubspot/jsonnet/identity.jsonnet index 63322e0..22f5ea6 100644 --- a/crm/hubspot/jsonnet/identity.jsonnet +++ b/crm/hubspot/jsonnet/identity.jsonnet @@ -1,7 +1,11 @@ -function(ctx) { - identity_id: ctx.identity.id, - email: ctx.identity.traits.email, - first_name: if std.objectHas(ctx.identity.traits, "name") then ctx.identity.traits.name.first else null, - last_name: if std.objectHas(ctx.identity.traits, "name") then ctx.identity.traits.name.last else null, - created_at: ctx.identity.created_at, -} +function(ctx) + local traits = ctx.identity.traits; + local name = std.get(traits, 'name', null); + local nameFields = if std.isObject(name) then name else {}; + { + identity_id: ctx.identity.id, + email: std.get(traits, 'email', null), + first_name: std.get(nameFields, 'first', null), + last_name: std.get(nameFields, 'last', null), + created_at: ctx.identity.created_at, + } diff --git a/crm/hubspot/ory-actions.yaml b/crm/hubspot/ory-actions.yaml index a4077c4..1f4615e 100644 --- a/crm/hubspot/ory-actions.yaml +++ b/crm/hubspot/ory-actions.yaml @@ -1,14 +1,52 @@ -hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/hubspot/sync-user - method: POST - body: file://./jsonnet/identity.jsonnet - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header - response: - ignore: true +# HubSpot contact sync hooks. +# +# Ory Network doesn't accept file:// bodies. Encode the template with +# `base64 < jsonnet/identity.jsonnet | tr -d '\n'` and paste it after base64://. + +selfservice: + flows: + registration: + after: + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/hubspot/sync-user + method: POST + body: base64:// + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + response: + ignore: true + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks + # The password list is aliased into the oidc, code, passkey, and webauthn + # lists. Alias it into any other method you enable, such as saml. + settings: + after: + profile: + hooks: + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/hubspot/sync-user + method: POST + body: base64:// + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + response: + ignore: true diff --git a/crm/hubspot/registry.entry.yaml b/crm/hubspot/registry.entry.yaml index 0bdc2af..16ea346 100644 --- a/crm/hubspot/registry.entry.yaml +++ b/crm/hubspot/registry.entry.yaml @@ -16,7 +16,7 @@ useCase: >- the Ory identity id stored on a custom property so any system can reverse-lookup. The integration upserts the contact via HubSpot's 409 Existing ID pattern so retries never duplicate. coreFunctionality: |- - - Sync Ory Action webhook (response.parse: true) on after:registration and (optionally) after:settings. + - Async Ory Action webhook (response.ignore: true) in the registration method lists (for example registration.after.password.hooks) and optionally settings.after.profile.hooks. - POST https://api.hubapi.com/crm/v3/objects/contacts with email, firstname, lastname, ory_identity_id. - On 409 Existing ID: parses the existing contact ID out of HubSpot's error message and PATCHes onto it for idempotent upsert. - 502 on HubSpot errors so the failure surfaces in Ory instead of being silently dropped. diff --git a/crm/microsoft-dynamics-365/README.md b/crm/microsoft-dynamics-365/README.md index da1c64d..338d92e 100644 --- a/crm/microsoft-dynamics-365/README.md +++ b/crm/microsoft-dynamics-365/README.md @@ -14,10 +14,10 @@ An enterprise running Microsoft Dynamics 365 as its CRM wants every Ory-managed ## How it works 1. A user registers or updates their profile in an Ory flow. -2. Ory fires the sync (or async) Action webhook to this handler. The handler verifies the shared secret. +2. Ory fires the asynchronous Action webhook (`response.ignore: true`) to this handler. The handler verifies the shared secret. 3. The handler exchanges its Entra ID client credentials for an OAuth2 access token (cached, refreshed 5 min before expiry) scoped to `${DYNAMICS_RESOURCE_URL}/.default`. -4. The handler upserts the contact via `PATCH /api/data/v9.2/contacts(ory_identity_id='')` with `If-Match: *`. Dataverse routes the request to the alternate key and creates the row if missing. -5. The handler returns `200` to Ory. +4. The handler upserts the contact via `PATCH /api/data/v9.2/contacts(ory_identity_id='')`. Dataverse routes the request to the alternate key and creates the row if missing. The request sends neither `If-Match` nor `If-None-Match`: `If-Match: *` would turn the upsert into an update-only call that fails for new users. +5. The handler returns `200`. Ory doesn't wait for the response, but it retries a `429` or `5xx` response other than `501`, for up to three attempts in total. ## Prerequisites @@ -45,8 +45,12 @@ The server listens on the port specified in `.env` (default 3000) and exposes: ## Configure Ory -1. Configure the Action hook using the snippet in [`ory-actions.yaml`](ory-actions.yaml) — register on `after:registration` and `after:settings`. -2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet). The default destructures `name.first`, `name.last`, and `phone` from traits — adjust if your identity schema uses different keys. +1. Configure the Action hooks using the snippet in [`ory-actions.yaml`](ory-actions.yaml). Attach them to the method-specific lists, not to the flow-level `after.hooks` lists: + - Registration: `selfservice.flows.registration.after..hooks` for each sign-up method you enable, such as `password` and `oidc`. On Ory Network, the `password` list contains `session`; keep it last. + - Profile updates: `selfservice.flows.settings.after.profile.hooks`. + + See [Attach `after` actions to authentication methods](https://www.ory.com/docs/kratos/hooks/configure-hooks#attach-after-actions-to-authentication-methods). +2. The body template is [`jsonnet/identity.jsonnet`](jsonnet/identity.jsonnet). It reads `name.first`, `name.last`, and `phone` from traits and sends `null` when they're missing — adjust if your identity schema uses different keys. Ory Network doesn't accept `file://` bodies, so set `body` to `base64://` followed by the Base64-encoded template. 3. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value in the hook config. Entra app registration, Dynamics application-user setup, and alternate-key indexing: see the [docs page](https://ory.com/docs/integrates-with/crm/microsoft-dynamics-365). @@ -56,7 +60,6 @@ Entra app registration, Dynamics application-user setup, and alternate-key index - **`401 invalid webhook secret`** — `ORY_WEBHOOK_SECRET` in `.env` doesn't match `X-Webhook-Secret` in the Ory hook config. - **`401` from Dataverse** — the Entra app isn't added as an Application User inside Dynamics, or the assigned security role lacks Contact write. App-permission alone isn't sufficient. - **`404` on PATCH** — the `ory_identity_id` alternate key isn't configured or hasn't finished indexing. The alternate-key route returns 404 until the index job completes. -- **`412 Precondition Failed`** — `If-Match: *` header is required for upsert. Without it Dataverse rejects the PATCH on existing rows. - **`entra token 401 AADSTS7000215`** — `ENTRA_CLIENT_SECRET` is invalid or expired. Regenerate in Entra → app → Certificates & secrets. ## License diff --git a/crm/microsoft-dynamics-365/jsonnet/identity.jsonnet b/crm/microsoft-dynamics-365/jsonnet/identity.jsonnet index 3c474e7..48291af 100644 --- a/crm/microsoft-dynamics-365/jsonnet/identity.jsonnet +++ b/crm/microsoft-dynamics-365/jsonnet/identity.jsonnet @@ -1,8 +1,12 @@ -function(ctx) { - identity_id: ctx.identity.id, - email: ctx.identity.traits.email, - first_name: if std.objectHas(ctx.identity.traits, "name") then ctx.identity.traits.name.first else null, - last_name: if std.objectHas(ctx.identity.traits, "name") then ctx.identity.traits.name.last else null, - phone: if std.objectHas(ctx.identity.traits, "phone") then ctx.identity.traits.phone else null, - created_at: ctx.identity.created_at, -} +function(ctx) + local traits = ctx.identity.traits; + local name = std.get(traits, 'name', null); + local nameFields = if std.isObject(name) then name else {}; + { + identity_id: ctx.identity.id, + email: std.get(traits, 'email', null), + first_name: std.get(nameFields, 'first', null), + last_name: std.get(nameFields, 'last', null), + phone: std.get(traits, 'phone', null), + created_at: ctx.identity.created_at, + } diff --git a/crm/microsoft-dynamics-365/ory-actions.yaml b/crm/microsoft-dynamics-365/ory-actions.yaml index 9d9930c..648af61 100644 --- a/crm/microsoft-dynamics-365/ory-actions.yaml +++ b/crm/microsoft-dynamics-365/ory-actions.yaml @@ -1,14 +1,52 @@ -hooks: - - hook: web_hook - config: - url: https://your-deployed-handler.example.com/dynamics/sync-user - method: POST - body: file://./jsonnet/identity.jsonnet - auth: - type: api_key - config: - name: X-Webhook-Secret - value: - in: header - response: - ignore: true +# Microsoft Dynamics 365 contact sync hooks. +# +# Ory Network doesn't accept file:// bodies. Encode the template with +# `base64 < jsonnet/identity.jsonnet | tr -d '\n'` and paste it after base64://. + +selfservice: + flows: + registration: + after: + password: + hooks: ®istration_after_hooks + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/dynamics/sync-user + method: POST + body: base64:// + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + response: + ignore: true + - hook: session + oidc: + hooks: *registration_after_hooks + code: + hooks: *registration_after_hooks + passkey: + hooks: *registration_after_hooks + webauthn: + hooks: *registration_after_hooks + # The password list is aliased into the oidc, code, passkey, and webauthn + # lists. Alias it into any other method you enable, such as saml. + settings: + after: + profile: + hooks: + - hook: web_hook + config: + url: https://your-deployed-handler.example.com/dynamics/sync-user + method: POST + body: base64:// + auth: + type: api_key + config: + name: X-Webhook-Secret + value: + in: header + response: + ignore: true diff --git a/crm/microsoft-dynamics-365/registry.entry.yaml b/crm/microsoft-dynamics-365/registry.entry.yaml index 7a43aba..86e423d 100644 --- a/crm/microsoft-dynamics-365/registry.entry.yaml +++ b/crm/microsoft-dynamics-365/registry.entry.yaml @@ -16,7 +16,7 @@ useCase: >- in Dataverse — automatically, with stable cross-system identifiers — so sales, marketing, and service teams see Ory users in their existing pipeline without separate exports. coreFunctionality: |- - - Ory Action webhook on registration.after / settings.after upserts a Contact in Dynamics 365 via the Dataverse Web API (OData v4). + - Async Ory Action webhook in the registration method lists (for example registration.after.password.hooks) and settings.after.profile.hooks upserts a Contact in Dynamics 365 via the Dataverse Web API (OData v4). - Upsert keyed by an `ory_identity_id` alternate key on the Contact entity — must be created in Dataverse before first run. - Dataverse auth via Microsoft Entra ID OAuth2 client credentials (Server-to-Server app registration with delegated/application permission for the Dataverse environment). - Region-aware: the Dataverse endpoint is per-environment (https://.crm.dynamics.com/api/data/v9.2). diff --git a/crm/microsoft-dynamics-365/webhook/server.ts b/crm/microsoft-dynamics-365/webhook/server.ts index 2754e11..9dfbc58 100644 --- a/crm/microsoft-dynamics-365/webhook/server.ts +++ b/crm/microsoft-dynamics-365/webhook/server.ts @@ -105,8 +105,8 @@ app.post( try { const token = await getToken(); - // Upsert by ory_identity_id alternate key (recommended) — falls back to - // email match if the alternate key isn't configured on Dataverse. + // A PATCH on the alternate key without If-Match or If-None-Match is an + // upsert: Dataverse creates the row when no contact has this key. const upsertRes = await fetch( `${DYNAMICS_RESOURCE_URL}/api/data/v9.2/contacts(ory_identity_id='${encodeURIComponent(identity_id)}')`, { @@ -116,7 +116,6 @@ app.post( "content-type": "application/json", "OData-Version": "4.0", "OData-MaxVersion": "4.0", - "If-Match": "*", // upsert (create or update) Prefer: "return=representation", }, body: JSON.stringify({ diff --git a/crm/pipedrive/README.md b/crm/pipedrive/README.md index b27b6dd..9c60f6d 100644 --- a/crm/pipedrive/README.md +++ b/crm/pipedrive/README.md @@ -15,7 +15,7 @@ A sales-led B2B product wants every registered user mirrored into Pipedrive as a 1. **Sync (async post-registration / post-settings)** — Ory calls `POST /pipedrive/sync-user` with `identity.id`, traits, and `created_at`. The handler returns `200` immediately, then searches Pipedrive for an existing person by email and either creates a new person or updates the existing one, writing `ory_identity_id` to a custom field. 2. **Track login (async post-login)** — Ory calls `POST /pipedrive/track-login-activity` with identity, session metadata, and request headers. The handler returns `200` immediately, finds the Pipedrive person by email, and creates a `task`-type activity with the login details (time, IP, device, method, session preview) marked done. -3. Both hooks are fire-and-forget so Pipedrive availability never blocks user flows. +3. All hooks set `response.ignore: true`, so Ory sends them in the background and Pipedrive availability never blocks user flows. ## Prerequisites @@ -42,8 +42,13 @@ The server listens on the port specified in `.env` (default 3000) and exposes: ## Configure Ory -1. Configure the three Action hooks using the snippets in [`ory-actions.yaml`](ory-actions.yaml) (post-registration, post-settings, post-login). -2. Body templates: [`jsonnet/sync-user.jsonnet`](jsonnet/sync-user.jsonnet) and [`jsonnet/track-login.jsonnet`](jsonnet/track-login.jsonnet). +1. Configure the three Action hooks using the snippets in [`ory-actions.yaml`](ory-actions.yaml). Attach each hook to the method-specific lists, not to the flow-level `after.hooks` lists: + - Registration: `selfservice.flows.registration.after..hooks` for each sign-up method you enable, such as `password` and `oidc`. On Ory Network, the `password` list contains `session`; keep it last. + - Settings: `selfservice.flows.settings.after.profile.hooks`. + - Login: `selfservice.flows.login.after..hooks` for each sign-in method. + + See [Attach `after` actions to authentication methods](https://www.ory.com/docs/kratos/hooks/configure-hooks#attach-after-actions-to-authentication-methods). +2. Body templates: [`jsonnet/sync-user.jsonnet`](jsonnet/sync-user.jsonnet) and [`jsonnet/track-login.jsonnet`](jsonnet/track-login.jsonnet). Ory Network doesn't accept `file://` bodies, so set each `body` to `base64://` followed by the Base64-encoded template. The login template reads the client IP address from the `True-Client-Ip` request header. 3. Set `ORY_WEBHOOK_SECRET` in the handler's `.env` to match the `X-Webhook-Secret` value in the hook config. @@ -60,3 +65,5 @@ A sample of the webhook response Pipedrive returns lives at [`assets/sample_webh ## License Apache-2.0. SPDX header at the top of each source file. + +The handler acknowledges successful vendor calls with `200` and returns `502` on failure so Ory can retry without delaying the user flow. Retries are bounded; use a durable queue for longer outages. Segment retries reuse message IDs; Pipedrive activity retries may produce duplicates after an ambiguous timeout. diff --git a/crm/pipedrive/jsonnet/track-login.jsonnet b/crm/pipedrive/jsonnet/track-login.jsonnet index 4d34175..d05b75f 100644 --- a/crm/pipedrive/jsonnet/track-login.jsonnet +++ b/crm/pipedrive/jsonnet/track-login.jsonnet @@ -1,22 +1,13 @@ // Body for the async post-login Ory Action that logs a login activity on -// the corresponding Pipedrive person. -function(ctx) { - id: ctx.identity.id, - traits: ctx.identity.traits, - login_time: if std.objectHas(ctx, "session") then ctx.session.authenticated_at else null, - ip_address: - if std.objectHas(ctx, "request_headers") && std.objectHas(ctx.request_headers, "True-Client-Ip") - then ctx.request_headers["True-Client-Ip"][0] - else if std.objectHas(ctx, "request_headers") && std.objectHas(ctx.request_headers, "x-forwarded-for") - then ctx.request_headers["x-forwarded-for"][0] - else null, - user_agent: - if std.objectHas(ctx, "request_headers") && std.objectHas(ctx.request_headers, "User-Agent") - then ctx.request_headers["User-Agent"][0] - else null, - login_method: - if std.objectHas(ctx, "session") && std.objectHas(ctx.session, "authentication_methods") - then std.toString(ctx.session.authentication_methods) - else null, - session_id: if std.objectHas(ctx, "session") then ctx.session.id else null, -} +// the corresponding Pipedrive person. Login actions always receive ctx.session. +function(ctx) + local header(name) = std.get(ctx.request_headers, name, [null])[0]; + { + id: ctx.identity.id, + traits: ctx.identity.traits, + login_time: std.get(ctx.session, 'authenticated_at', null), + ip_address: header('True-Client-Ip'), + user_agent: header('User-Agent'), + login_method: std.join(',', [m.method for m in std.get(ctx.session, 'authentication_methods', [])]), + session_id: ctx.session.id, + } diff --git a/crm/pipedrive/ory-actions.yaml b/crm/pipedrive/ory-actions.yaml index bd97a18..c12d60e 100644 --- a/crm/pipedrive/ory-actions.yaml +++ b/crm/pipedrive/ory-actions.yaml @@ -1,59 +1,82 @@ -# Pipedrive sync + login-activity hooks. +# Pipedrive person sync and login-activity hooks. # -# Both hooks are fire-and-forget (response.ignore: true, can_interrupt: -# false). The handler returns 200 to Ory immediately and runs Pipedrive -# calls in the background. +# All hooks set response.ignore: true, so Ory sends them in the background and +# Pipedrive availability never blocks user flows. +# +# Ory Network doesn't accept file:// bodies. Encode each template with +# `base64 < jsonnet/