Skip to content

feat: add support for user attributes#887

Open
refiito wants to merge 3 commits intomainfrom
feat/add-user-attributes-support
Open

feat: add support for user attributes#887
refiito wants to merge 3 commits intomainfrom
feat/add-user-attributes-support

Conversation

@refiito
Copy link
Copy Markdown

@refiito refiito commented Apr 6, 2026

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

@claude
Copy link
Copy Markdown

claude Bot commented Apr 6, 2026

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
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 6, 2026

📦 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
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 6, 2026

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
Copy link
Copy Markdown
Member

@cngonzalez cngonzalez left a comment

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

@@ -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)


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?

(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

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.

> {
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)

}

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


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.


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)

},
{
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.

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