Skip to content

feat: add support for user attributes#887

Open
refiito wants to merge 5 commits into
mainfrom
feat/add-user-attributes-support
Open

feat: add support for user attributes#887
refiito wants to merge 5 commits into
mainfrom
feat/add-user-attributes-support

Conversation

@refiito

@refiito refiito commented Apr 6, 2026

Copy link
Copy Markdown

Add CLI support for User Attributes API

What

Adds CLI commands for managing User Attributes — the foundation for parameterised role-based access control. User attributes are key-value pairs (e.g. location="UK", departments=["hr","sales"]) that can be referenced in role definitions and access filters.

Note: The User Attributes API is currently in preview (vX). These commands are subject to change as the API stabilises.

Commands

Attribute definitions (org-level schema — requires sanity.organization.manage):

  sanity users attributes definitions list  --org-id <id> [--json]
  sanity users attributes definitions create --org-id <id> --key <key> --type <type> [--json]
  sanity users attributes definitions delete --org-id <id> <key>

User attribute values (list defaults to your own user; set/unset require sanity.organization.manage):

  sanity users attributes list  --org-id <id> [--user-id <id>] [--json]
  sanity users attributes set   --org-id <id> --user-id <id> --attributes '[{"key":"location","value":"UK"}]' [--json]
  sanity users attributes unset --org-id <id> --user-id <id> --key <key> [--key <key>...]

When --org-id is omitted in an interactive terminal, the CLI prompts for an organization selection.

Testing

pnpm test packages/@sanity/cli/src/commands/users/attributes


Note

Medium Risk
New org-scoped commands can change user metadata and attribute schemas (access-control related), though behavior is gated by API permissions and the surface is preview (vX) with validation and tests.

Overview
Adds CLI support for the preview User Attributes API (vX), enabling org admins to manage attribute schemas and per-user values used for parameterized access control.

New sanity users attributes commands cover list / set / unset on user values (with --user-id, JSON --attributes validation, and table or --json output) and definitions list / create / delete for org-level keys and types. A shared userAttributes service wraps the HTTP client; promptForOrganization plus a reusable --organization / -o flag (getOrganizationFlag) supply the org when omitted in interactive mode.

Ships broad nock-based command tests, a small formatAttributeValue helper, a minor @sanity/cli changeset, and a trivial test import order fix in decorateIndexWithStagingScript.test.ts.

Reviewed by Cursor Bugbot for commit 8547a24. Bugbot is set up for automated code reviews on this repo. Configure here.

@claude

claude Bot commented Apr 6, 2026

Copy link
Copy Markdown

Claude finished @refiito's task in 3m 1s —— View job


Code Review

  • Gather context
  • Read changed files
  • Identify issues

1. set.ts — Input validation doesn't check key is a string

set.ts:94-98 validates that each attribute item has key and value fields, but 'key' in item doesn't verify the type. An input like [{"key": 123, "value": "UK"}] silently passes as SetAttributeInput[] and reaches the API.

// Current — insufficient
if (typeof item !== 'object' || item === null || !('key' in item) || !('value' in item)) {

// Fix — add type check
if (typeof item !== 'object' || item === null || typeof (item as any).key !== 'string' || !('value' in item)) {

Fix this →


2. userAttributes.ts — DELETE with request body is non-standard

deleteUserAttributes (services/userAttributes.ts:107-111) sends a body with method: 'DELETE'. All other DELETE calls in the codebase use URL parameters only. Some HTTP intermediaries (proxies, gateways) strip bodies from DELETE requests per RFC 7231. If the API supports it, consider a POST to a /bulk-delete endpoint or encoding keys as query params instead.


3. unset.ts:70 — Success message uses the flag value, not the API response

this.log(`Attribute${keys.length === 1 ? '' : 's'} removed successfully for user ${userId}.`)

userId is the raw flag input. The deleteUserAttributes call returns a UserAttributesResponse containing sanityUserId, but the result is discarded. If the API normalizes IDs, the message can be misleading. Compare with set.ts:115 which correctly uses result.sanityUserId.


4. promptForOrganization.ts — 6.7% test coverage

Only the NonInteractiveError throw path is tested (via the other commands' tests when --org-id is missing in non-interactive mode). The listOrganizations() call, empty-org case, spinner lifecycle, and select() are all untested. This is the only truly new utility introduced — it warrants dedicated tests.


@github-actions

github-actions Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @sanity/cli

Compared against main (512b79e1)

@sanity/cli

Metric Value vs main (512b79e)
Internal (raw) 2.1 KB -
Internal (gzip) 799 B -
Bundled (raw) 10.95 MB -
Bundled (gzip) 2.06 MB -
Import time 845ms +13ms, +1.5%

bin:sanity

Metric Value vs main (512b79e)
Internal (raw) 975 B -
Internal (gzip) 460 B -
Bundled (raw) 9.84 MB -
Bundled (gzip) 1.77 MB -
Import time 2.04s +56ms, +2.8%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — @sanity/cli-core

Compared against main (512b79e1)

Metric Value vs main (512b79e)
Internal (raw) 92.3 KB -
Internal (gzip) 21.6 KB -
Bundled (raw) 21.53 MB -
Bundled (gzip) 3.41 MB -
Import time 815ms +20ms, +2.6%

🗺️ View treemap · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

📦 Bundle Stats — create-sanity

Compared against main (512b79e1)

Metric Value vs main (512b79e)
Internal (raw) 976 B -
Internal (gzip) 507 B -
Bundled (raw) 50.7 KB -
Bundled (gzip) 12.6 KB -
Import time ❌ ChildProcess denied: node -
Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

@github-actions

github-actions Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Coverage Delta

File Statements
packages/@sanity/cli/src/actions/userAttributes/constants.ts 100.0% (new)
packages/@sanity/cli/src/commands/users/attributes/definitions/create.ts 79.2% (new)
packages/@sanity/cli/src/commands/users/attributes/definitions/delete.ts 75.0% (new)
packages/@sanity/cli/src/commands/users/attributes/definitions/list.ts 83.3% (new)
packages/@sanity/cli/src/commands/users/attributes/list.ts 82.1% (new)
packages/@sanity/cli/src/commands/users/attributes/set.ts 81.6% (new)
packages/@sanity/cli/src/commands/users/attributes/unset.ts 72.2% (new)
packages/@sanity/cli/src/prompts/promptForOrganization.ts 6.7% (new)
packages/@sanity/cli/src/services/userAttributes.ts 100.0% (new)
packages/@sanity/cli/src/util/formatAttributeValue.ts 100.0% (new)
packages/@sanity/cli/src/util/sharedFlags.ts 96.0% (- 4.0%)

Comparing 11 changed files against main @ 52f56c11e7b14f25b103650b8774bb20d8e82425

Overall Coverage

Metric Coverage
Statements 83.0% (- 0.1%)
Branches 72.8% (- 0.1%)
Functions 83.4% (+ 0.1%)
Lines 83.4% (- 0.1%)

refiito and others added 2 commits April 7, 2026 18:48
- Add pagination notice when definitions list results are truncated (hasMore)
- Move array validation outside the JSON.parse try/catch in set.ts to avoid fragile error re-throw
- Extract duplicated formatValue helper to shared util/formatAttributeValue.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add per-item validation in set.ts to catch missing key/value fields early
- Remove redundant `as UserAttribute[]` casts (already typed correctly via response interfaces)
- Remove now-unused UserAttribute import from list.ts and set.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@refiito refiito marked this pull request as ready for review April 11, 2026 07:07
@refiito refiito requested a review from a team as a code owner April 11, 2026 07:07
@refiito refiito requested review from cngonzalez and removed request for a team April 11, 2026 07:07

@cngonzalez cngonzalez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Functionally looks pretty good! Most comments are nits about style and guidelines

/**
* Get the authenticated user's own attributes within an organization
*/
export async function getMyAttributes(orgId: string): Promise<UserAttributesGetResponse> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"My" is a little idiosyncratic in this repo -- maybe getCliUserAttributes (to match the getCliUser function in user.ts

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Renamed to getCliUserAttributes.

@@ -0,0 +1,56 @@
export type AttributeType =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm a bit confused on why this and the constants file are in the actions directory when there's no corresponding actions. Should they be moved to commands where they're actually used? Or do we foresee a need for actions down the line (multi-step processes etc)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good point — there were no action files. I inlined the types and the USER_ATTRIBUTES_API_VERSION constant into services/userAttributes.ts, which matches the pattern in services/organizations.ts (types live in the service file that owns them). The actions/userAttributes/ directory is gone.


export type AttributeValue = (number | string)[] | boolean | number | string

export interface UserAttributeValues {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It might be worth making a comment on this type -- I'm a little confused because it seems to be used as the value of an attribute as part of UserAttribute, which has an activeSource that indicates if it's saml or sanity but there's a possibility for an attribute to have a value that is not in line with with the activeSource on the attribute? Is it shared or updated perhaps?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added a JSDoc clarifying the distinction:

Raw per-source values for a single attribute. Each entry holds the value received from that source (e.g. asserted in a SAML assertion, or set explicitly through Sanity). Both may be present at once; the value the API and access rules use is UserAttribute.activeValue, picked according to UserAttribute.activeSource.

To answer the question directly: yes, both saml and sanity can hold values simultaneously, which can differ. The resolved value is on the outer UserAttribute (activeSource + activeValue); values is the raw per-source state.

(baseDescription ?? 'Organization ID to use') + (isOverride ? OVERRIDE_SUFFIX : '')

return {
'org-id': Flags.string({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not the final say on this, but I wanted to point out that elsewhere the CLI uses flags to specify an org, it's --organization (like in init and projects create). We may want to be consistent with the rest of the CLI

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Renamed to --organization everywhere: helper is now getOrganizationFlag, flag is --organization (kept the -o short form). All 5 callers + their tests updated. Verified getOrgIdFlag had no consumers outside this PR.

For now --organization accepts an org ID only (matching the API). projects create accepts slug-or-id; we can extend ours similarly in a follow-up if the API gains slug support.

export class UserAttributeDefinitionsCreateCommand extends SanityCommand<
typeof UserAttributeDefinitionsCreateCommand
> {
static override description = 'Create an attribute definition for an organization'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would "user attribute definition" be clearer here? I'm on the fence since the command is sanity user attribute definition create.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Updated all three definitions command descriptions (create, list, delete) to use "user attribute definition", and the corresponding success/info log lines too.

> {
static override description = 'Create an attribute definition for an organization'

static override examples = [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe not required for every command, but it might be nice to provide examples that illustrate that the organization ID is not needed for these commands, since we're prompting for it.

(I think we also have a style guide rule for progressive complexity which you're doing a great job of here! Just think the "no org" example could be nice)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added no-org examples to all three definitions commands (create, list, delete) — they show that running without --organization will prompt for one in interactive mode.

}

if (outputJson) {
this.log(JSON.stringify(result, null, 2))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There is a colorizeJson helper available that might be nice for consistency

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Switched all four --json output paths to colorizeJson (definitions list/create and attributes list/set). Color is only emitted in interactive TTY, so --json | jq still parses fine.


if (result.hasMore) {
this.log(
'\nNote: Results are truncated. Use --json and the API directly with a cursor to fetch more.',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would it make sense for the user to run another CLI command to get a cursor or just hit the API directly (assuming it already gives a cursor?) It might also be nice to give them the URL to hit, if they need a token, etc.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Leaving the truncation note as-is for this PR — the User Attributes API is still in preview (vX) and the pagination contract may change. Once it's stable I'll come back and either thread the cursor through the CLI or document the API URL + token guidance in the help text, in a follow-up. Filed as a TODO for the User Attributes Followups project.


static override examples = [
{
command: '<%= config.bin %> <%= command.id %> --org-id o123 location',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Generally, examples tend to position arguments before flags (though technically I think OCLIF allows either)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reordered: example is now sanity users attributes definitions delete location --organization o123 (positional arg first). Also added a no-org variant.

},
{
command:
'<%= config.bin %> <%= command.id %> --org-id o123 --user-id u456 --attributes \'[{"key":"location","value":"UK"},{"key":"year_started","value":2020}]\'',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure if we have a standard or recommendations about passing JSON on the command line. @binoy14 might have more thoughts about this or has encountered this before.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Leaving the JSON-on-CLI shape as-is in this PR — happy to revise if @binoy14 has a stronger convention. Current shape is a JSON-array string passed to --attributes, which matches what we already do for other multi-value structured flags. Open to either: (1) a documented JSON-array stays, (2) per-attribute flag pairs like --key location --value UK (repeatable), (3) a --attributes-file for larger payloads. Tracking as a separate decision rather than blocking this PR.

@refiito refiito marked this pull request as draft May 14, 2026 07:29
refiito and others added 2 commits June 8, 2026 22:04
Reviewer feedback addressed:

- set.ts: validate that each attribute item has a string `key`, not just
  any present `key` field (Claude bot)
- unset.ts: use `result.sanityUserId` from the API response in the success
  message instead of the raw user-id flag, mirroring set.ts (Claude bot)
- Rename `--org-id` to `--organization` across all user-attributes commands
  and the shared `getOrganizationFlag` helper, for consistency with `init`
  and `projects create` (cngonzalez)
- Rename `getMyAttributes` to `getCliUserAttributes`, matching the
  `getCliUser` naming in services/user.ts (cngonzalez)
- Inline the user-attributes types and API-version constant into
  services/userAttributes.ts, matching the pattern in services/organizations.ts.
  Drops the misleading actions/userAttributes/ directory (cngonzalez)
- Add JSDoc on UserAttributeValues explaining how the per-source `saml`/
  `sanity` entries relate to the resolved `activeSource`/`activeValue` on
  UserAttribute (cngonzalez)
- Use `colorizeJson` from cli-core for the `--json` output paths in list,
  set, definitions/list and definitions/create (cngonzalez)
- Reword command descriptions to "user attribute definition" so they match
  the command path (cngonzalez)
- Add "no --organization" examples to create/list/delete definitions
  commands, showing the interactive-prompt case (cngonzalez)
- definitions/delete example: place positional arg before flags (cngonzalez)
- Add unit tests for promptForOrganization (Claude bot — 6.7% coverage)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@refiito

refiito commented Jun 8, 2026

Copy link
Copy Markdown
Author

Round 3 of review feedback addressed in b9c5b4c. Summary of disposition on the Claude bot review:

1. set.ts key string check — addressed. Validation now requires typeof item.key === 'string', and the error message names the constraint.
2. userAttributes.ts DELETE with body — left as-is. The User Attributes API is in preview (vX), the Sanity API gateway is ours end-to-end, and the cli-core HTTP client preserves the body through to the request. If a real-world intermediary issue surfaces I'll revisit; the failure mode the comment describes is theoretical for our deployment topology.
3. unset.ts success message uses flag value — addressed. Now uses result.sanityUserId from the API response, mirroring set.ts.
4. promptForOrganization.ts coverage — addressed. New test file at prompts/__tests__/promptForOrganization.test.ts covers the NonInteractive throw, the empty-orgs case, the fetch-failure path with spinner state, the success path with formatted choices, and the spinner-succeed lifecycle.

Plus all of @cngonzalez's inline nits — see individual thread replies.

@refiito refiito marked this pull request as ready for review June 9, 2026 06:27
@refiito refiito requested a review from a team as a code owner June 9, 2026 06:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants