Cloudflare Zero Trust Seat Cleanup

Building a safe Cloudflare Zero Trust seat cleanup utility with PowerShell and Postman

Important: The repository discussed in this post is an unofficial, personal/community utility. It is not a Cloudflare product and is not provided or supported by Cloudflare. Review the code, run dry runs first, and use removal mode only when you understand the operational impact.

Cloudflare Zero Trust seat management is simple at the conceptual layer: a user consumes a seat when they authenticate into the Zero Trust stack through Access or Gateway/WARP, and a seat is freed when the associated seat flags are cleared. The operational problem is less simple: mature environments accumulate inactive users, contractors, test identities, IdP remnants, and one-off offboarding lists. Cleaning those seats manually is slow, inconsistent, and easy to get wrong.

The Cloudflare Access License Manager repository is a small automation project built around that gap. It provides two equivalent interfaces:


Both interfaces implement the same safety model: enumerate users first, build an explicit candidate set, write or display a report, and only remove seats after an explicit operator action.

The API surface area

The project deliberately uses a narrow API surface:

Operation Endpoint Purpose
List Zero Trust users GET /accounts/{account_id}/access/users Retrieve users, seat flags, identifiers, and login metadata.
Free Zero Trust seats PATCH /accounts/{account_id}/access/seats Update seat records by seat_uid.
Verify token GET /user/tokens/verify Confirm the token is valid before an operator proceeds.

The user-list endpoint returns the fields this utility needs to make deterministic decisions: email, id, uid, seat_uid, access_seat, gateway_seat, last_successful_login, and created_at. The removal endpoint accepts an array of seat update objects. The critical implementation detail is that a seat is released only when both seat flags are cleared:

json
[
  {
    "seat_uid": "<seat_uid>",
    "access_seat": false,
    "gateway_seat": false
  }
]

That means the utility does not try to free “only Access” or “only Gateway”. The SeatType setting is a targeting filter — it decides which users qualify for the candidate set — but the removal operation always clears both access_seat and gateway_seat, because that is the API behavior that frees the billable seat.

Two targeting modes

The repo originally focused on inactivity-based cleanup. It now supports two independent targeting modes.

1. Inactivity mode

Inactivity mode selects users whose login reference is older than a configurable threshold:

plaintext
./scripts/Remove-InactiveAccessSeats.ps1 -InactiveDays 90 -OutputPath report.json

For users with last_successful_login, the script compares that timestamp to now - InactiveDays. If last_successful_login is null, the default behavior is to use created_at as the reference point. This is intentional: a user who was provisioned 300 days ago and never logged in can still consume administrative attention and may still hold a seat. If that is not desired, -ExcludeNeverLoggedIn skips never-logged-in users entirely.

The same model is implemented in Postman under the Inactivity Mode folder. The first request pages through users and builds flaggedSeats; the preview request verifies the API token and displays the set; the remove request is guarded by confirmRemoval = true.

2. Explicit user-list mode

User-list mode exists for workflows that are not based on inactivity: offboarding, contractor cleanup, identity reconciliation, or incident response.

plaintext
./scripts/Remove-InactiveAccessSeats.ps1   -UserList alice@example.com,bob@example.com   -OutputPath offboarding-report.json

The script can also ingest a file:

plaintext
./scripts/Remove-InactiveAccessSeats.ps1 -UserListPath ./offboard.csv -Remove

Supported file formats are intentionally straightforward:


Internally, the script resolves the supplied tokens against the full user list. Emails are matched case-insensitively; IDs and seat UIDs are matched as identifiers. Entries that cannot be found, or that resolve to users holding no matching seat, are reported and skipped. Resolved seats are de-duplicated by seat_uid so the same user can appear in multiple input files without causing duplicate PATCH entries.

The Postman collection mirrors this with a User-List Mode folder. Operators populate the userList variable with comma-, whitespace-, or newline-separated emails/IDs/seat UIDs. The collection pages through all users, resolves the list, reports unresolved entries to the Postman console, and populates the same flaggedSeats variable used by the shared removal request.

SeatType is a filter, not a partial-removal mechanism

Both targeting modes accept SeatType:

SeatType Candidate requirement
Access access_seat = true
Gateway gateway_seat = true
Either either flag is true
Both both flags are true

This is useful for answering questions such as “only consider Gateway/WARP users from this offboarding list” or “only report users holding both kinds of seats.” But once a user is selected and removal is confirmed, the removal body clears both flags. The implementation treats the API contract as the source of truth and avoids presenting an unsafe illusion of partial seat release.

Safety model

The script is intentionally conservative:


Postman has a similar guardrail: removal refuses to run unless confirmRemoval is set to true, and the guard resets after a successful removal request.

Pagination and rate limiting

The list-users endpoint is paginated. The PowerShell script requests per_page=1000 and loops through result_info.total_pages. The Postman collection uses postman.setNextRequest() so Collection Runner/Newman runs continue until all pages have been scanned.

The PowerShell API wrapper also handles HTTP 429 responses with a small retry loop, honoring Retry-After when present and falling back to a simple increasing wait. That logic lives in one function, Invoke-CfApi, so both list and patch operations get the same error handling.

Why both PowerShell and Postman?

PowerShell and Postman serve different operational audiences.

PowerShell is better for:


Postman is better for:


The important design choice is that the two interfaces do not diverge semantically. They both enumerate the account, build a candidate set, preview it, and submit the same removal body.

Test design

The repo includes a local mock API in test/mock_cf.js. It implements the three endpoints needed by the utility and returns seven fixture users that cover the edge cases:


The PowerShell suite validates both modes end-to-end: all SeatType filters, never-logged-in behavior, inline and file-based user-list parsing, de-duplication, unmatched entries, removal bodies, and -WhatIf non-mutation.

The Postman suite runs mode folders with Newman. It verifies that the collection resolves both inactivity and explicit user-list workflows, sends the right seat_uid values, and always clears both seat flags.

At the time of writing, the validation results are:


Operational guidance

For a production Zero Trust account, I would treat this utility as a reporting system first and a removal system second.

A reasonable rollout pattern is:


For user-list mode, validate the input source. A malformed offboarding CSV should not become a destructive operation. The tool reports skipped entries, but the operator still owns the source-of-truth quality of the list.

Limitations and explicit non-goals

This utility does not:


It is a narrow tool for one job: find or resolve Zero Trust seat holders, preview the candidate set, and free those seats through the public API when explicitly instructed.

Repository

The code is available at: https://github.com/Dgilmore-CF/cloudflare-access-license-manager

Again, this is an unofficial utility. It is not a Cloudflare product, and it is not provided or supported by Cloudflare.

Comments

Be the first to comment on this post!

Leave a Comment

Your comment will be reviewed before appearing on the site.

Your email will not be published
0 / 5000 characters