Skip to content

cisco_duo: add user data stream and latest transform - #20768

Merged
efd6 merged 2 commits into
elastic:mainfrom
efd6:e25619-cisco_duo
Aug 20, 2026
Merged

cisco_duo: add user data stream and latest transform#20768
efd6 merged 2 commits into
elastic:mainfrom
efd6:e25619-cisco_duo

Conversation

@efd6

@efd6 efd6 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Proposed commit message

cisco_duo: add user data stream and latest transform

Adds a user data stream that periodically polls GET /admin/v1/users
to collect the full Duo user inventory. Because the endpoint is
snapshot-only (it has no time-range filtering), each poll retrieves
all users from offset 0. A latest Elasticsearch transform
(logs-cisco_duo_latest.user) keeps one current record per user ID,
sorted by event.ingested, so consumers always see up-to-date user
state rather than the historical append of every poll.

Pipeline test inputs in _dev/test/pipeline/test-user.log and the
mock server responses in _dev/deploy/docker/files/config.yml are
synthetic data hand-crafted to match the response structure
described in the Duo Admin API documentation:
https://duo.com/docs/adminapi#users

Checklist

  • I have reviewed tips for building integrations and this pull request is aligned with them.
  • I have verified that all data streams collect metrics or logs.
  • I have added an entry to my package's changelog.yml file.
  • I have verified that Kibana version constraints are current according to guidelines.
  • I have verified that any added dashboard complies with Kibana's Dashboard good practices

Author's Checklist

  • [ ]

How to test this PR locally

Related issues

Screenshots

@efd6 efd6 self-assigned this Aug 18, 2026
@efd6 efd6 added enhancement New feature or request Integration:cisco_duo Cisco Duo Team:Security-Service Integrations Security Service Integrations team [elastic/security-service-integrations] labels Aug 18, 2026
@efd6
efd6 force-pushed the e25619-cisco_duo branch from b5eba84 to 8a8ec00 Compare August 18, 2026 04:25
@github-actions

Copy link
Copy Markdown
Contributor

Elastic Docs Style Checker (Vale)

Summary: 1 suggestion found

💡 Suggestions (1): Optional style improvements. Apply when helpful.
File Line Rule Message
packages/cisco_duo/data_stream/user/fields/fields.yml 31 Elastic.WordChoice Consider using 'deactivated, deselected, hidden, turned off, unavailable' instead of 'disabled', unless the term is in the UI.

The Vale linter checks documentation changes against the Elastic Docs style guide. To use Vale locally or report issues, refer to Elastic style guide for Vale.

@elastic-vault-github-plugin-prod

elastic-vault-github-plugin-prod Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

🚀 Benchmarks report

Package cisco_duo 👍(3) 💚(2) 💔(4)

Expand to view
Data stream Previous EPS New EPS Diff (%) Result
activity 4149.38 3215.43 -933.95 (-22.51%) 💔
auth 2652.52 2141.33 -511.19 (-19.27%) 💔
summary 50000 28571.43 -21428.57 (-42.86%) 💔
telephony 58823.53 41666.67 -17156.86 (-29.17%) 💔

To see the full report comment with /test benchmark fullreport

@efd6
efd6 marked this pull request as ready for review August 18, 2026 05:30
@efd6
efd6 requested review from a team as code owners August 18, 2026 05:30
@infra-vault-gh-plugin-prod

Copy link
Copy Markdown

Pinging @elastic/security-service-integrations (Team:Security-Service Integrations)

delay: 120s
retention_policy:
time:
field: "@timestamp"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟠 High confidence: high path: packages/cisco_duo/elasticsearch/transform/latest_user/transform.yml:31

The retention policy expires destination documents on @​timestamp, but the pipeline sets @​timestamp to the user's account-creation date, so every user created more than 30 days ago is deleted from the latest index. Retain on event.ingested instead, or drop the retention policy.

Details

The new ingest pipeline's first date processor (packages/cisco_duo/data_stream/user/elasticsearch/ingest_pipeline/default.yml, tag date_created) parses json.created with no target_field, so it overwrites @​timestamp with the Duo account-creation time. The committed fixture confirms this: in test-user.log-expected.json @​timestamp equals cisco_duo.user.created (2021-07-15T12:27:34.000Z) for both documents.

The retention policy here deletes destination documents whose @​timestamp is older than 30 days. Because @​timestamp is the account-creation date rather than an observation time, it never advances as the user is re-collected, so any account older than 30 days is written by the transform and then immediately purged. For a typical tenant the logs-cisco_duo_latest.user alias would be empty or contain only recently created accounts, which contradicts the transform's stated purpose ("Retains only the most recent record per user for up-to-date correlation").

Retaining on event.ingested expresses the intended semantics — drop users that have not been seen from the API for 30 days — and matches the field already used for sync and latest.sort. It requires the event.ingested declaration described in finding 2. Alternatively, remove retention_policy entirely, or stop overwriting @​timestamp in the pipeline so it reflects collection time.

Recommendation:

Expire on the field that tracks when the record was last seen:

retention_policy:
  time:
    field: "event.ingested"
    max_age: 30d

This depends on event.ingested being declared in the transform's field definitions (see the related finding on fields/ecs.yml), otherwise the destination index maps it as a keyword and the retention policy cannot use it.


🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

Comment on lines +23 to +24
- external: ecs
name: user.name

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟠 High confidence: high path: packages/cisco_duo/elasticsearch/transform/latest_user/fields/ecs.yml:24

event.ingested is used as the destination index sort field and is written into every destination document, but it is not declared in the transform's field definitions, so the generated index template has no mapping for it. Add an external: ecs entry for event.ingested.

Details

elasticsearch/transform/latest_user/manifest.yml sets index.sort.field: ["event.ingested"] on the destination index template, and transform.yml uses event.ingested for both latest.sort and sync.time.field. None of the five files under elasticsearch/transform/latest_user/fields/ declares event.ingested, so the destination index template carries no mapping for it.

Two consequences: Elasticsearch resolves index.sort.* against the mappings present at index-creation time and rejects creation with unknown index sort field:[event.ingested] when the field is unmapped — dynamic templates do not satisfy this because they only apply as documents are indexed. And even setting the sort aside, a latest transform copies the whole source document, so event.ingested lands in the destination index and, with date_detection: false plus the strings_as_keyword dynamic template, is mapped as keyword — conflicting with its date mapping in logs-cisco_duo.user-*.

Every comparable transform in the repo that references event.ingested declares it explicitly (for example packages/ti_misp/elasticsearch/transform/latest_ioc/fields/ecs.yml and packages/ti_opencti/elasticsearch/transform/latest_ioc/fields/ecs.yml).

Recommendation:

Declare event.ingested alongside the other external ECS fields so the destination index template maps it as a date before the index is created.

Suggested change
- external: ecs
name: user.name
- external: ecs
name: user.name
- external: ecs
name: event.ingested

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

ignore_missing: true
if: ctx.event?.original != null
description: 'The `message` field is no longer required if the document has an `event.original` field.'
- json:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: medium path: packages/cisco_duo/data_stream/user/elasticsearch/ingest_pipeline/default.yml:43

A document produced by the CEL program's error branch has neither message nor event.original, so this json processor fails and the collector error is re-reported as a generic pipeline error. Add a terminate processor ahead of it to short-circuit collector-error documents.

Details

The CEL program in agent/stream/cel.yml.hbs returns the single-object error shape {"events": {"error": {...}}} on any non-200 response. That produces a document carrying error.message (and error.code/error.id) with no message field, so rename_event_original is a no-op and this json processor runs against a missing event.original without ignore_missing. It throws, the pipeline-level on_failure fires, and the document is indexed with event.kind: pipeline_error plus a second appended error.message — masking the original HTTP status and message the collector reported.

This data stream is new and the package enables agentless deployment (policy_templates[0].deployment_modes.agentless.enabled: true), so the CEL-opening error guard applies here. The terminate processor is available on the stack range allowed by conditions.kibana.version (^8.18.0 || ^9.0.0).

Recommendation:

Stop processing collector-error documents before any parsing runs (place it with the other opening processors, ahead of rename_event_original):

  - terminate:
      tag: terminate_collector_error
      if: ctx.error?.message != null && ctx.message == null
      description: >-
        Stop processing documents that report a CEL collector error
        and carry no payload to parse.

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

bool:
must_not:
- term:
event.kind: pipeline_error

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: medium path: packages/cisco_duo/elasticsearch/transform/latest_user/transform.yml:8

The transform source query only excludes event.kind: pipeline_error, which misses documents whose failure came from a per-processor on_failure, and it scans cold/frozen tiers. Add error.message and _tier exclusions.

Details

The date_created processor in the new pipeline has its own on_failure that appends to error.message without setting event.kind: pipeline_error, so a user record with an unparseable created value keeps event.kind: state and passes this filter into the latest index. Excluding documents that have error.message catches both that case and the pipeline-level failures already covered.

Separately, with frequency: 1m and continuous sync this transform repeatedly queries logs-cisco_duo.user-* with no tier restriction, so once older backing indices roll into the cold or frozen tier the checkpoint searches hit them.

Recommendation:

Filter out error documents and expensive tiers in the source query:

source:
  index:
    - "logs-cisco_duo.user-*"
  query:
    bool:
      must_not:
        - term:
            event.kind: pipeline_error
        - exists:
            field: error.message
        - terms:
            _tier:
              - data_frozen
              - data_cold

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

@efd6
efd6 force-pushed the e25619-cisco_duo branch from 8a8ec00 to 3f1b955 Compare August 18, 2026 05:54
- input: cel
enabled: false
vars:
- name: limit

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟠 High confidence: high path: packages/cisco_duo/data_stream/user/manifest.yml:7

The new user data stream declares no interval variable of its own, so it inherits the shared policy-template default of 1m and re-fetches the entire Duo user directory every 60 seconds. Add a stream-level interval var with an inventory-appropriate default such as 24h.

Details

The cel input in the root manifest defines interval with default: 1m and the description "Not recommended requesting logs more than once per minute" (packages/cisco_duo/manifest.yml:130-137). That default is sized for the incremental event-log streams (activity, auth, telephony, trust_monitor), which page a bounded time window each run.

This stream's template reuses the same value (interval: {{interval}} in data_stream/user/agent/stream/cel.yml.hbs:2), but the program is a full-inventory pull, not an incremental one: whenever want_more is false it resets offset to "0" and pages through every user again (cel.yml.hbs:16-24). Because the stream manifest declares no interval var, an operator cannot slow it down without also slowing every other Cisco Duo stream.

Concretely, with the shipped defaults limit: 100 and rate_limit: "0.5" (0.5 req/s = 30 requests/min), a tenant with more than ~3000 users cannot finish a single pass inside a 1-minute interval, so the input polls the Duo Admin API back-to-back continuously and writes a full copy of the user directory into logs-cisco_duo.user-* on every pass. The latest transform deduplicates the destination index but does nothing to reduce the source-index volume or the API load.

Comparable user-inventory data streams declare their own interval: packages/island_browser/data_stream/user/manifest.yml:11-18 uses default: 24h while that package's event-log streams use 30m/1h.

Recommendation:

Declare an interval var on the user stream. A stream-level var shadows the policy-template input var of the same name, so this lets the inventory poll run on its own schedule while the event-log streams keep their 1m default:

    vars:
      - name: interval
        type: text
        title: Interval
        description: "Duration between requests to the Cisco Duo Admin API for the full user inventory. NOTE: Supported units for this parameter are h/m/s."
        multi: false
        required: true
        show_user: true
        default: 24h
      - name: limit
        type: integer
        title: Limit
        description: Maximum number of records to fetch on each request. Max is 300.
        show_user: false
        required: true
        default: 100

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

field: event.original
target_field: json
tag: json_event_original
- date:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: medium path: packages/cisco_duo/data_stream/user/elasticsearch/ingest_pipeline/default.yml:53

The date_created processor has no target_field, so it overwrites @​timestamp with the user's account-creation date and inventory documents land years in the past; drop it and keep the guarded date_created_field processor that already writes cisco_duo.user.created.

Details

Two problems with this processor.

  1. No target_field means the date processor writes @​timestamp. The generated fixtures show the effect: sample_event.json has @​timestamp: 2021-05-01T00:00:00.000Z against event.ingested: 2026-08-18T04:07:18Z. For an event.kind: state inventory stream the collection time is the meaningful timestamp — a directory of accounts created over many years is scattered across the time axis, so a default Discover or dashboard time filter shows little or nothing, and the freshness of the inventory is invisible. Comparable state/inventory data streams only date-convert into their own namespaced field and leave @​timestamp at the agent collection time (packages/lastpass/data_stream/user/elasticsearch/ingest_pipeline/default.yml:30-32, packages/kolide/data_stream/people/elasticsearch/ingest_pipeline/default.yml:144-150).

  2. Unlike its sibling on the very next processor (line 64, which carries if: ctx.json?.created != null), this one is unguarded. A user record without created therefore fails it and appends to error.message. The latest transform's source query excludes every document with error.message present (packages/cisco_duo/elasticsearch/transform/latest_user/transform.yml:9-11), so such a user would silently never reach logs-cisco_duo_latest.user.

Removing this processor fixes both: cisco_duo.user.created is still populated by the guarded processor below it.

Recommendation:

Delete the unguarded processor so @​timestamp stays at the collection time, leaving only the guarded date conversions:

  - json:
      field: event.original
      target_field: json
      tag: json_event_original
  - date:
      field: json.created
      target_field: cisco_duo.user.created
      tag: date_created_field
      formats:
        - UNIX
      if: ctx.json?.created != null
  - date:
      field: json.last_login
      target_field: cisco_duo.user.last_login
      tag: date_last_login
      formats:
        - UNIX
      if: ctx.json?.last_login != null

Note that test-user.log-expected.json and sample_event.json will need regenerating, since both currently encode the account-creation date as @​timestamp.


🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

Comment on lines +19 to +36
- set:
field: event.kind
tag: set_event_kind
value: state
- set:
field: event.category
tag: set_event_category
value: ["iam"]
- set:
field: event.type
tag: set_event_type
value: ["user", "info"]
- terminate:
tag: terminate_collector_error
if: ctx.error?.message != null && ctx.message == null && ctx.event?.original == null
description: >-
Stop processing documents that report a CEL collector error
and carry no payload to parse.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🔵 Low confidence: high path: packages/cisco_duo/data_stream/user/elasticsearch/ingest_pipeline/default.yml:36

terminate_collector_error runs after event.kind, event.category and event.type are set, so CEL collector-error documents get indexed as state/iam/user,info events; move the terminate ahead of those three set processors.

Details

A CEL collector error is published as a document carrying only error.* and no message, which is exactly what the terminate at line 31 is there to stop. But the three set processors at lines 19-30 have already run by then, so the error document is indexed with event.kind: state, event.category: ["iam"] and event.type: ["user", "info"] — indistinguishable from a real Duo user record in IAM dashboards, counts and detection rules that filter on those fields. Reordering costs nothing and does not change the pipeline test output, since the test documents all carry message and never trigger the terminate.

Recommendation:

Move the terminate block above the three event.* set processors so collector-error documents stop before any event categorisation is applied.

Suggested change
- set:
field: event.kind
tag: set_event_kind
value: state
- set:
field: event.category
tag: set_event_category
value: ["iam"]
- set:
field: event.type
tag: set_event_type
value: ["user", "info"]
- terminate:
tag: terminate_collector_error
if: ctx.error?.message != null && ctx.message == null && ctx.event?.original == null
description: >-
Stop processing documents that report a CEL collector error
and carry no payload to parse.
- terminate:
tag: terminate_collector_error
if: ctx.error?.message != null && ctx.message == null && ctx.event?.original == null
description: >-
Stop processing documents that report a CEL collector error
and carry no payload to parse.
- set:
field: event.kind
tag: set_event_kind
value: state
- set:
field: event.category
tag: set_event_category
value: ["iam"]
- set:
field: event.type
tag: set_event_type
value: ["user", "info"]

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

@efd6
efd6 force-pushed the e25619-cisco_duo branch from 3f1b955 to c1fc6d9 Compare August 18, 2026 06:21
@@ -0,0 +1,72 @@
{
"@timestamp": "2021-05-01T00:00:00.000Z",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: high path: packages/cisco_duo/data_stream/user/sample_event.json:2

The sample event was generated before the date_created fix, so its @​timestamp is the account-creation date (2021-05-01) while event.ingested is 2026-08-18; regenerate sample_event.json from a fresh system test so @​timestamp reflects collection time.

Details

The pipeline no longer writes @​timestamp at all: date_created_field now targets cisco_duo.user.created (default.yml:53-59), and nothing else sets @​timestamp, so the agent's collection time is what lands in the document. The pipeline test's expected file confirms this, as it contains no @​timestamp key.

This sample event, however, has @​timestamp exactly equal to cisco_duo.user.created (2021-05-01T00:00:00.000Z, from created: 1619827200) while event.ingested is 2026-08-18T04:07:18Z. A five-year gap between collection time and ingest time is not producible by the current pipeline, so this file was generated against the earlier revision that had the unguarded date processor (see prior review finding on default.yml) and was not regenerated after that processor was fixed.

This matters beyond tidiness: packages/cisco_duo/docs/README.md embeds this JSON verbatim as the documented example for the user dataset, so the published docs show a timestamp the integration cannot produce. It also makes the sample useless for sanity-checking the transform, whose latest.sort is @​timestamp.

Recommendation:

Re-run the system test for this data stream and commit the regenerated sample_event.json, then rebuild the docs so packages/cisco_duo/docs/README.md picks up the new example. After regeneration @​timestamp should sit alongside event.ingested rather than matching cisco_duo.user.created:

{
    "@​timestamp": "2026-08-18T04:07:15.123Z",
    "cisco_duo": {
        "user": {
            "created": "2021-05-01T00:00:00.000Z",
            "last_login": "2021-07-22T11:26:40.000Z"
        }
    },
    "event": {
        "ingested": "2026-08-18T04:07:18Z"
    }
}

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

type: constant_keyword
description: Data stream dataset.
- name: data_stream.namespace
type: constant_keyword

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: medium path: packages/cisco_duo/elasticsearch/transform/latest_user/fields/base-fields.yml:8

The transform destination maps data_stream.namespace as a valueless constant_keyword, so once the first document pins it to one namespace, documents collected under any other Fleet namespace are rejected; declare it as keyword instead.

Details

A constant_keyword field with no value in the mapping configures itself from the first indexed document and then rejects any document carrying a different value. That is safe in the source data stream, where each backing index belongs to exactly one namespace, but the transform's source pattern is logs-cisco_duo.user-* (transform.yml:3) and every namespace converges into the single destination index logs-cisco_duo_latest.dest_user-1.

data_stream.namespace is present in the source _source (visible in this PR's sample_event.json, where it is 58062), so the latest transform copies it through. As soon as the integration is configured on a second agent policy with a different namespace, those documents fail to index and the transform accumulates index failures rather than reflecting the full user inventory.

data_stream.type and data_stream.dataset are not affected: their values are constant across all source documents (logs and cisco_duo.user), so constant_keyword is correct for those two. Only namespace varies. Several packages that hit this already override the type explicitly, e.g. packages/aws/elasticsearch/transform/latest_cdr_misconfigurations/fields/base-fields.yml.

Recommendation:

Map the namespace as a plain keyword in the transform's destination field definitions so documents from any namespace are accepted:

- name: data_stream.namespace
  type: keyword
  description: Data stream namespace.

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.


{{fields "trust_monitor"}}

### User

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🔵 Low confidence: high path: packages/cisco_duo/_dev/build/docs/README.md:120

The new user dataset section is added to the docs, but the "Data streams" bullet list further up the same file still lists only the eight pre-existing datasets; add a Retrieve Users entry there too.

Details

_dev/build/docs/README.md has a "Data streams" section (lines 26-37) enumerating every event type the integration collects, each linking to the corresponding Cisco Duo Admin API doc anchor. This PR appends a ### User section at the bottom of the file but leaves that list untouched, so a reader scanning the overview will not learn that user inventory collection exists. Every other data stream in the package is represented there, so the omission reads as an oversight rather than a deliberate choice.

The generated packages/cisco_duo/docs/README.md inherits the same gap.

Recommendation:

Add the user endpoint to the "Data streams" list alongside the existing entries, then rebuild the docs:

- [**Telephony Logs (legacy)**](https://duo.com/docs/adminapi#telephony-logs-(legacy-v1))
- [**Trust Monitor**](https://duo.com/docs/adminapi#trust-monitor)
- [**Users**](https://duo.com/docs/adminapi#retrieve-users)

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

@efd6
efd6 force-pushed the e25619-cisco_duo branch from c1fc6d9 to 25f7dd3 Compare August 18, 2026 06:38
- [**Telephony Logs**](https://duo.com/docs/adminapi#telephony-logs)
- [**Telephony Logs (legacy)**](https://duo.com/docs/adminapi#telephony-logs-(legacy-v1))
- [**Trust Monitor**](https://duo.com/docs/adminapi#trust-monitor)
- [**Users**](https://duo.com/docs/adminapi#retrieve-users)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: high path: packages/cisco_duo/_dev/build/docs/README.md:38

The new user data stream calls GET /admin/v1/users, which Duo gates behind the "Grant resource - Read" Admin API permission, but the Requirements section still lists only "Grant read information" and "Grant read log" - add the resource-read permission to that list.

Details

The Requirements section of this template (line 21, unchanged by this PR) tells administrators: "For this integration you will require Grant read information and Grant read log permissions." Duo's Admin API reference states for Retrieve Users: 'Requires "Grant resource - Read" API permission.' That permission covers "resource objects such as end users, policies, and devices" and is a separate checkbox on the Admin API application from the log/information grants the package uses today - it is not implied by the administrator's Owner role.

An Admin API application provisioned exactly as the README describes therefore returns HTTP 403 with body {"stat": "FAIL", "code": 40301, "message": "Access forbidden"} for this endpoint. The CEL program's non-200 branch converts that into a collector-error document rather than user records, and the ingest pipeline's terminate_collector_error processor stops it, so the new data stream silently produces nothing for anyone who followed the documented setup.

Fix this in the _dev/build/docs/README.md template; docs/README.md is generated from it and must be regenerated.

Recommendation:

Extend the permissions bullet in the Requirements section so the resource-read grant is listed alongside the existing ones:

- For this integration you will require **Grant read information** and **Grant read log** permissions. The **Users** data stream additionally requires the **Grant resource - Read** permission, which is what authorizes `GET /admin/v1/users`.

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

list.removeIf(v -> v == null || v == '' || (v instanceof Map && v.size() == 0) || (v instanceof List && v.size() == 0));
}
handleMap(ctx);
- remove:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: high path: packages/cisco_duo/data_stream/user/elasticsearch/ingest_pipeline/default.yml:207

The pipeline renames a fixed allowlist of user attributes and then removes the whole json object, so documented Duo user fields such as lockout_reason, last_directory_sync, external_id and u2ftokens are silently discarded - add renames (and matching field definitions) for the attributes worth keeping.

Details

The Custom fields block renames sixteen specific keys out of json, and this processor then deletes json outright. Anything the Duo Admin API returns that is not in that allowlist never reaches the document.

Duo documents these additional top-level user attributes that the allowlist omits: lockout_reason, last_directory_sync, external_id, directory_key, u2ftokens, desktop_authenticators, enable_auto_prompt, password_last_updated, date_of_birth, entra_federated_user_id, and alias1 through alias4.

lockout_reason is the most consequential loss: this PR's own field definition for cisco_duo.user.status documents the value "locked out", and Duo states the reason for that lockout is carried in lockout_reason. The data stream therefore surfaces the locked-out state while dropping the only field that explains it. last_directory_sync, external_id and directory_key are the fields needed to correlate Duo users with the upstream directory, and u2ftokens / desktop_authenticators are authenticator inventory - the core value of a user inventory data stream for a security integration.

This is a deliberate-looking allowlist, so if some of these are intentionally out of scope that is fine, but the security-relevant ones above are worth capturing while the mapping is being written rather than in a follow-up that needs another field-mapping change.

Recommendation:

Add renames for the attributes worth retaining before the remove: json step, for example:

  - rename:
      field: json.lockout_reason
      target_field: cisco_duo.user.lockout_reason
      ignore_missing: true
      tag: rename_lockout_reason
  - rename:
      field: json.last_directory_sync
      target_field: cisco_duo.user.last_directory_sync
      ignore_missing: true
      tag: rename_last_directory_sync
  - rename:
      field: json.external_id
      target_field: cisco_duo.user.external_id
      ignore_missing: true
      tag: rename_external_id
  - rename:
      field: json.u2ftokens
      target_field: cisco_duo.user.u2ftokens
      ignore_missing: true
      tag: rename_u2ftokens

Note that last_directory_sync is a Unix timestamp, so it needs a date processor like created/last_login rather than a plain rename if it is mapped as a date. Add the corresponding entries to both copies of the field definitions - data_stream/user/fields/fields.yml and elasticsearch/transform/latest_user/fields/fields.yml - since they are kept byte-identical:

    - name: lockout_reason
      type: keyword
      description: |
        The reason the user is locked out, when status is `locked out`.
    - name: external_id
      type: keyword
      description: |
        The user's ID in an external directory.
    - name: u2ftokens
      type: flattened
      description: |
        List of U2F tokens associated with the user.

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.


### User

This is the `user` dataset.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🔵 Low confidence: medium path: packages/cisco_duo/_dev/build/docs/README.md:123

This PR installs a latest transform but the README never mentions it, so users have no way to discover the logs-cisco_duo_latest.user alias or the transform privileges it needs - add the standard transform note the other transform-shipping packages use.

Details

The PR adds elasticsearch/transform/latest_user, which creates the destination index logs-cisco_duo_latest.dest_user-1 and the alias logs-cisco_duo_latest.user, and starts a managed transform (start: true, unattended: true). None of this is documented: the README describes the user dataset only, so a reader cannot tell that a transform is installed, that querying the deduplicated latest state means targeting the alias rather than logs-cisco_duo.user-*, or that installing the package now requires transform privileges.

This is the convention in the other packages that ship latest transforms - island_browser, axonius, extrahop, ironscales, ibm_qradar, jupiter_one, cyera, xm_cyber and the ti_* packages all carry the same note plus a transform-health troubleshooting step. Adding it here keeps cisco_duo consistent with them and gives users a place to look when the transform goes unhealthy.

Recommendation:

Add the standard note near the top of the template (the other packages place it just after the Data streams list), and regenerate docs/README.md:

This integration installs [Elastic latest transforms](https://www.elastic.co/docs/explore-analyze/transforms/transform-overview#latest-transform-overview). For more details, check the [Transform](https://www.elastic.co/docs/explore-analyze/transforms/transform-setup) setup and requirements.

The latest state of each Duo user is available through the `logs-cisco_duo_latest.user` alias.

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

Adds a user data stream that periodically polls GET /admin/v1/users
to collect the full Duo user inventory. Because the endpoint is
snapshot-only (it has no time-range filtering), each poll retrieves
all users from offset 0. A latest Elasticsearch transform
(logs-cisco_duo_latest.user) keeps one current record per user ID,
sorted by event.ingested, so consumers always see up-to-date user
state rather than the historical append of every poll.

Pipeline test inputs in _dev/test/pipeline/test-user.log and the
mock server responses in _dev/deploy/docker/files/config.yml are
synthetic data hand-crafted to match the response structure
described in the Duo Admin API documentation:
https://duo.com/docs/adminapi#users
@efd6
efd6 force-pushed the e25619-cisco_duo branch from 25f7dd3 to 6e11835 Compare August 18, 2026 07:53
@@ -0,0 +1,2 @@
{"user_id":"DUJXXXXXXXXXXXXXXXXX","username":"narroway","realname":"Narrow Way","email":"narroway@example.com","status":"active","created":1626352054,"last_login":1626953200,"notes":"","is_enrolled":true,"firstname":"Narrow","lastname":"Way","groups":[{"group_id":"DGXXXXXXXXXXXXXXXXXX","name":"Employees","status":"Active","desc":""}],"phones":[{"phone_id":"DPXXXXXXXXXXXXXXXXXX","number":"+15555551234","type":"mobile","platform":"Apple iOS","activated":true}],"tokens":[],"webauthncredentials":[],"custom_attributes":{},"aliases":{}}
{"user_id":"DUJYYYYYYYYYYYYYYYYY","username":"jsmith","realname":"Jane Smith","email":"jsmith@example.com","status":"disabled","created":1619827200,"is_enrolled":false,"groups":[],"phones":[],"tokens":[],"webauthncredentials":[],"custom_attributes":{},"aliases":{}}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Severity: 🟡 Medium confidence: medium path: packages/cisco_duo/data_stream/user/_dev/test/pipeline/test-user.log:2

The four attribute handlers added in the latest revision (lockout_reason, external_id, u2ftokens, last_directory_sync) are not exercised by either fixture document, so a wrong target_field or date format in them would pass CI unnoticed - add a third fixture user that carries those keys.

Details

Both documents in this fixture come from the same Duo sample user shape: user_id, username, realname, email, status, created, last_login, notes, is_enrolled, firstname, lastname, groups, phones, tokens, webauthncredentials, custom_attributes, aliases. None of them contains lockout_reason, external_id, u2ftokens or last_directory_sync, so rename_lockout_reason, rename_external_id, rename_u2ftokens and date_last_directory_sync in default.yml never execute during elastic-package test pipeline, and the matching field definitions in fields/fields.yml are never type-checked against real values. last_directory_sync is the riskiest of the four: it is a UNIX-epoch integer converted by a date processor into a date-mapped field, and a format or target mistake there only surfaces in production. notes is likewise only present as an empty string, which the drop_null_values script removes, so the match_only_text mapping is never populated either. New data streams should have fixture coverage for every processor branch they ship.

Recommendation:

Append a third user to test-user.log that carries the untested attributes, then regenerate the expected file with elastic-package test pipeline --generate:

{"user_id":"DUJZZZZZZZZZZZZZZZZZ","username":"lockedout","realname":"Locked Out","email":"lockedout@​example.com","status":"locked out","created":1619827200,"last_login":1626953200,"last_directory_sync":1657227493,"lockout_reason":"pending deletion","external_id":"1a2345b6-7cd8-9e0f-g1hi-23j45kl6m789","notes":"Imported from directory sync","is_enrolled":true,"firstname":"Locked","lastname":"Out","groups":[],"phones":[],"tokens":[],"u2ftokens":[{"date_added":1444678994,"registration_id":"D2SXXXXXXXXXXXXXXXXX"}],"webauthncredentials":[],"custom_attributes":{},"aliases":{}}

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

"phones": [
{
"activated": true,
"number": "+15555551234",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you think we should mask/redact phone numbers behind an option?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We don't redact it elsewhere.

"iam"
],
"id": "DUJYYYYYYYYYYYYYYYYY",
"kind": "state",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you think we can set this to asset irrespective of ecs.version?

"preserve_original_event"
],
"user": {
"email": "jsmith@example.com",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you think user.email is a good enough identifier to be added to related.user?

https://www.elastic.co/docs/reference/ecs/ecs-related#field-related-user

@@ -0,0 +1,20 @@
- name: data_stream.type

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could use external: ecs on these fields.

@elastic-vault-github-plugin-prod

Copy link
Copy Markdown
Contributor

✅ All changelog entries have the correct PR link.

@vera-review-bot

Copy link
Copy Markdown

🟢 Reviewed the latest commits 50b57fc — nothing new beyond already posted comments.

Review summary

Issues found across earlier commits 6e11835 — 1 medium
  • 🟡 The four attribute handlers added in the latest revision (lockout_reason, external_id, u2ftokens, last_directory_sync) are not exercised by either fixture document, so a wrong target_field or date format in them would pass CI unnoticed - add a third fixture user that carries those keys. (link) (Unresolved)
Issues found across earlier commits 25f7dd3 — 2 medium, 1 low
  • 🟡 The new user data stream calls GET /admin/v1/users, which Duo gates behind the "Grant resource - Read" Admin API permission, but the Requirements section still lists only "Grant read information" and "Grant read log" - add the resource-read permission to that list. (link) (Unresolved)
  • 🟡 The pipeline renames a fixed allowlist of user attributes and then removes the whole json object, so documented Duo user fields such as lockout_reason, last_directory_sync, external_id and u2ftokens are silently discarded - add renames (and matching field definitions) for the attributes worth keeping. (link) (Unresolved)
  • 🔵 This PR installs a latest transform but the README never mentions it, so users have no way to discover the logs-cisco_duo_latest.user alias or the transform privileges it needs - add the standard transform note the other transform-shipping packages use. (link) (Unresolved)
Issues found across earlier commits c1fc6d9 — 2 medium, 1 low
  • 🟡 The sample event was generated before the date_created fix, so its @​timestamp is the account-creation date (2021-05-01) while event.ingested is 2026-08-18 (link) (Unresolved)
  • 🟡 The transform destination maps data_stream.namespace as a valueless constant_keyword, so once the first document pins it to one namespace, documents collected under any other Fleet namespace are rejected (link) (Unresolved)
  • 🔵 The new user dataset section is added to the docs, but the "Data streams" bullet list further up the same file still lists only the eight pre-existing datasets (link) (Unresolved)
Issues found across earlier commits 3f1b955 — 1 high, 1 medium, 1 low
  • 🟠 The new user data stream declares no interval variable of its own, so it inherits the shared policy-template default of 1m and re-fetches the entire Duo user directory every 60 seconds. Add a stream-level interval var with an inventory-appropriate default such as 24h. (link) (Unresolved)
  • 🟡 The date_created processor has no target_field, so it overwrites @​timestamp with the user's account-creation date and inventory documents land years in the past (link) (Unresolved)
  • 🔵 terminate_collector_error runs after event.kind, event.category and event.type are set, so CEL collector-error documents get indexed as state/iam/user,info events (link) (Unresolved)
Issues found across earlier commits 8a8ec00 — 2 high, 2 medium
  • 🟠 The retention policy expires destination documents on @​timestamp, but the pipeline sets @​timestamp to the user's account-creation date, so every user created more than 30 days ago is deleted from the latest index. Retain on event.ingested instead, or drop the retention policy. (link) (Unresolved)
  • 🟠 event.ingested is used as the destination index sort field and is written into every destination document, but it is not declared in the transform's field definitions, so the generated index template has no mapping for it. Add an external: ecs entry for event.ingested. (link) (Unresolved)
  • 🟡 A document produced by the CEL program's error branch has neither message nor event.original, so this json processor fails and the collector error is re-reported as a generic pipeline error. Add a terminate processor ahead of it to short-circuit collector-error documents. (link) (Unresolved)
  • 🟡 The transform source query only excludes event.kind: pipeline_error, which misses documents whose failure came from a per-processor on_failure, and it scans cold/frozen tiers. Add error.message and _tier exclusions. (link) (Unresolved)

A new commit triggers another review — at most once every 15 minutes. I skip the PR while it's approved or has merge conflicts.

🤖 AI-Generated Review | Vera Review Bot - v0.2.6 | 📚 Knowledge base: integration-skills

⚠️ Automated review — verify suggestions before applying.

@infra-vault-gh-plugin-prod

Copy link
Copy Markdown

💚 Build Succeeded

History

cc @efd6

@mergify

mergify Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request
@efd6
efd6 merged commit 5a5f9a5 into elastic:main Aug 20, 2026
11 checks passed
@elastic-vault-github-plugin-prod

Copy link
Copy Markdown
Contributor

Package cisco_duo - 2.12.0 containing this change is available at https://epr.elastic.co/package/cisco_duo/2.12.0/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Integration:cisco_duo Cisco Duo Team:Security-Service Integrations Security Service Integrations team [elastic/security-service-integrations]

2 participants