Add audit event for a PAT used from an unseen IP address

What does this MR do and why?

When a personal access token is used from an IP address not present in its last_used_ips history, a personal_access_token_used_from_unseen_ip audit event is recorded. This gives security teams visibility into PAT usage from new locations.

  • Reuses the existing IP-novelty detection in PersonalAccessTokens::LastUsedService (which maintains a rolling window of the last 5 IPs per token)
  • Fires the event only on subsequent uses — the first use of a token (when last_used_at is nil) does not emit an event, as there is no baseline
  • Extracts a shared ip_already_recorded? memoized helper to avoid duplicate DB queries between unseen_ip? and last_used_ip_needs_update?
  • The audit event message includes the unseen IP address for actionability
  • Available in CE; authentication_event: true events are persisted to the database and written to audit_json.log in all tiers
  • Also covers Group Access Tokens and Project Access Tokens since they are PersonalAccessToken records (owned by project_bot users) and flow through the same LastUsedService
  • Gated behind the audit_event_pat_unseen_ip feature flag (default disabled) to allow gradual rollout — can be enabled per-user, per-group, or by percentage

Feature flag

  • Name: audit_event_pat_unseen_ip
  • Type: development
  • Default: disabled
  • Scoped to: user (supports per-user, percentage-of-actors rollout)

To enable for a specific user:

Feature.enable(:audit_event_pat_unseen_ip, User.find_by_username('username'))

To enable for all users:

Feature.enable(:audit_event_pat_unseen_ip)

Implementation notes

  • unseen_ip? is evaluated before the without_sticky_writes block so the DB query reads the pre-insert state of last_used_ips
  • log_audit_event_for_unseen_ip is called after the without_sticky_writes block so the AuditEvent write is not suppressed
  • organization: is passed from @personal_access_token.organization (always present), avoiding the NOT NULL constraint on authentication_events.organization_id

Local GDK tests & Audit event examples

Establishing baseline IP over REST API
curl -H "PRIVATE-TOKEN: $TOKEN" http://gdk.test:3000/api/v4/personal_access_tokens/self
{
  "id": 25,
  "name": "another-token",
  "last_used_at": "2026-04-29T01:48:27.956Z",
  "last_used_ips": [
    "::1"
  ]
}
Triggering unseen IP via X-Forwarded-For
curl -H "PRIVATE-TOKEN: $TOKEN" -H "X-Forwarded-For: 172.16.0.99" http://gdk.test:3000/api/v4/personal_access_tokens/self
{
  "id": 25,
  "name": "another-token",
  "last_used_at": "2026-04-29T01:48:27.956Z",
  "last_used_ips": [
    "::1",
    "172.16.0.99"
  ]
}
Audit log entry (audit_json.log)
{
  "severity": "INFO",
  "time": "2026-04-29T01:48:28.149Z",
  "correlation_id": "01KQBERPCZ76G2ZEPE3D22Z06R",
  "meta.caller_id": "GET /api/:version/personal_access_tokens/self",
  "meta.remote_ip": "172.16.0.99",
  "meta.feature_category": "system_access",
  "id": 698,
  "author_id": 1,
  "entity_id": 1,
  "entity_type": "User",
  "details": {
    "pat_id": 25,
    "pat_name": "another-token",
    "event_name": "personal_access_token_used_from_unseen_ip",
    "author_name": "Administrator",
    "author_class": "User",
    "target_id": 1,
    "target_type": "User",
    "target_details": "Administrator",
    "custom_message": "Personal access token was used from a previously unseen IP address: 172.16.0.99",
    "ip_address": "172.16.0.99",
    "entity_path": "root"
  },
  "ip_address": "172.16.0.99",
  "author_name": "Administrator",
  "entity_path": "root",
  "target_details": "Administrator",
  "created_at": "2026-04-29T01:48:28.106Z",
  "target_type": "User",
  "target_id": 1,
  "event_name": "personal_access_token_used_from_unseen_ip",
  "custom_message": "Personal access token was used from a previously unseen IP address: 172.16.0.99"
}

How to validate locally

  1. Enable the feature flag:

    Feature.enable(:audit_event_pat_unseen_ip)
  2. Create a PAT and use it once to establish a baseline IP:

    curl -H "PRIVATE-TOKEN: $TOKEN" http://gdk.test:3000/api/v4/personal_access_tokens/self
  3. Use the PAT from a different IP:

    curl -H "PRIVATE-TOKEN: $TOKEN" -H "X-Forwarded-For: 172.16.0.99" http://gdk.test:3000/api/v4/personal_access_tokens/self
  4. Check audit log:

    grep unseen_ip log/audit_json.log

References

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

  • Tests added (27 examples, 0 failures)
  • Documentation updated (audit_event_types.md)
  • Audit event type YAML added
  • introduced_by_mr field updated with this MR's URL
  • GDK end-to-end validation (curl + audit_json.log)
  • Feature flag added (audit_event_pat_unseen_ip, default disabled)
Edited by Neil McDonald

Merge request reports

Loading