From 94a5df966756015cd8d00119cd1e2745b24153da Mon Sep 17 00:00:00 2001 From: Antoine Lecompte <38678863+nutgood@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:09:22 -0400 Subject: [PATCH] feat(yucca): per-user feature flags (#389) --- docs/feature-flags.md | 68 ++++ o11y/dashboards/yucca-michael.json | 116 ++---- packages/common/src/features.ts | 41 ++ packages/common/src/index.ts | 2 +- packages/yucca-admin-api/openapi-specs.json | 359 ++++++++++++++---- packages/yucca-admin-api/src/app.module.ts | 6 + .../src/controllers/features.controller.ts | 41 ++ .../src/controllers/user.controller.ts | 34 +- .../yucca-admin-api/src/dto/features.dto.ts | 105 +++++ .../repositories/featureFlag.repository.ts | 84 ++++ .../src/services/features.service.ts | 88 +++++ .../test/features.integration-spec.ts | 143 +++++++ packages/yucca-admin-api/test/testUtils.ts | 12 +- .../test/user.integration-spec.ts | 9 +- packages/yucca-api-client/openapi-specs.json | 9 +- packages/yucca-api-client/src/fetch-client.ts | 3 + packages/yucca-api/src/dto/auth.dto.ts | 3 + packages/yucca-api/src/enum.ts | 1 + .../yucca-api/src/middleware/auth.guard.ts | 12 + .../src/repositories/user.repository.ts | 17 +- packages/yucca-api/src/schema/index.ts | 3 + .../20260727190000-AddUserFeatureFlags.ts | 25 ++ .../yucca-api/src/schema/tables/user.table.ts | 3 + .../tables/userFeatureFlagOverride.table.ts | 30 ++ .../src/services/auth.service.spec.ts | 28 +- .../yucca-api/src/services/auth.service.ts | 10 +- packages/yucca-api/src/utils/exceptions.ts | 6 + .../yucca-api/test/auth.integration-spec.ts | 12 + packages/yucca-api/test/testUtils.ts | 10 + packages/yuctl/internal/adminapi/features.go | 104 +++++ packages/yuctl/internal/adminapi/http.go | 75 ++++ packages/yuctl/internal/cli/features.go | 118 ++++++ packages/yuctl/internal/cli/root.go | 1 + packages/yuctl/internal/cli/users.go | 143 +++++++ 34 files changed, 1554 insertions(+), 167 deletions(-) create mode 100644 docs/feature-flags.md create mode 100644 packages/common/src/features.ts create mode 100644 packages/yucca-admin-api/src/controllers/features.controller.ts create mode 100644 packages/yucca-admin-api/src/dto/features.dto.ts create mode 100644 packages/yucca-admin-api/src/repositories/featureFlag.repository.ts create mode 100644 packages/yucca-admin-api/src/services/features.service.ts create mode 100644 packages/yucca-admin-api/test/features.integration-spec.ts create mode 100644 packages/yucca-api/src/schema/migrations/20260727190000-AddUserFeatureFlags.ts create mode 100644 packages/yucca-api/src/schema/tables/userFeatureFlagOverride.table.ts create mode 100644 packages/yuctl/internal/adminapi/features.go create mode 100644 packages/yuctl/internal/adminapi/http.go create mode 100644 packages/yuctl/internal/cli/features.go diff --git a/docs/feature-flags.md b/docs/feature-flags.md new file mode 100644 index 00000000..e50a8abb --- /dev/null +++ b/docs/feature-flags.md @@ -0,0 +1,68 @@ +# Feature flags + +Per-user product gating for yucca. The system is built so it lasts: **the set +of flags is code, the state of flags is data.** + +## Model + +- **Registry (code)** — `packages/common/src/features.ts`, exported from + `@common/server` as `FeatureFlags`. Each entry has a `key`, a boolean + `default`, a `stage`, a `description`, and the `since` version. This is the + single source of truth for what flags exist and what they default to. +- **Overrides (data)** — the `userFeatureFlagOverride` table (one row per + deliberate per-user decision): `(userId, flag)` unique, a boolean `value`, + plus `setBy` (the admin `sub`) and `reason` for the audit trail. +- **Resolution** — everywhere, a user's effective flags are + `resolveFeatures(overrides)`: the override wins, otherwise the registry + default. Overrides for flags no longer in the registry are ignored. + +Adding a flag is a one-object change in the registry — no migration. Clearing a +user's override reverts them to the registry default, which is **not** the same +as setting an override to `false` (a `false` override is a deliberate opt-out / +kill-switch that survives a GA default flip). + +## Where it's enforced and surfaced + +- **yucca-api** gates routes with `@RequireFeature('')` stacked on + `@AuthRoute()` (403 when off), and returns the resolved flags on `GET /auth` + as `features: Record` — so web, the orchestrator SDK, and + the CLI clients all read the same resolved state. +- **Device flow** honors flags too: a non-immich `connection_type` on + `/auth/oidc/device` fails with `FEATURE_NOT_ENABLED` unless the type's flag + (`connection-restic`) is on. immich needs no flag. + +## Lifecycle (`stage`) + +| Stage | Default | Meaning | +|---|---|---| +| `experimental` | off | manual per-user overrides only | +| `beta` | off | manual + cohort enrollment (`features enable-batch`) | +| `ga` | on | default flipped in the registry via a release; `false` overrides act as opt-outs | +| `retired` | — | gate code deleted; prune orphaned override rows | + +GA is a code change that ships as a release (matching how prod promotion already +works), not a runtime toggle — so "what does user X get?" is always answerable +from the registry plus their override row. + +## Managing flags (yuctl) + +```bash +yuctl features list # registry: defaults, stages, override counts +yuctl features users connection-restic # who has an override, set by whom, why +yuctl users features list # one user's resolved flags + overrides +yuctl users features set connection-restic on --reason "early access" +yuctl users features clear connection-restic +yuctl features enable-batch connection-restic 50 # oldest 50 users without an override +``` + +Batch enrollment orders by `users.createdAt` then `id` (users predating the +connection migration share `2026-01-01`, tie-broken by id). + +## The boundary rule (why this isn't a config junk drawer) + +- **env / cluster-settings** = *deployment* config: per-partition, ops-owned, + needs a deploy to change (e.g. `REDIS_ADDR`, OIDC issuer, replica counts). +- **feature flags** = *per-user product gating*: runtime, admin-owned, no deploy. + +A flag never reads from env; an env var never varies per user. Keep them +separate and the system stays legible. diff --git a/o11y/dashboards/yucca-michael.json b/o11y/dashboards/yucca-michael.json index 543fb8b4..ab5b213f 100644 --- a/o11y/dashboards/yucca-michael.json +++ b/o11y/dashboards/yucca-michael.json @@ -68,9 +68,7 @@ "justifyMode": "auto", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -134,9 +132,7 @@ "justifyMode": "auto", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -199,9 +195,7 @@ "justifyMode": "auto", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -257,9 +251,7 @@ "justifyMode": "auto", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -314,9 +306,7 @@ "justifyMode": "auto", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -372,9 +362,7 @@ "justifyMode": "auto", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -461,9 +449,7 @@ "id": 9, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -542,9 +528,7 @@ "id": 10, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -623,9 +607,7 @@ "id": 11, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -720,9 +702,7 @@ "id": 12, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -801,9 +781,7 @@ "id": 18, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -901,9 +879,7 @@ "id": 19, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -996,9 +972,7 @@ "id": 21, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -1088,9 +1062,7 @@ "id": 22, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -1170,9 +1142,7 @@ "id": 23, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -1252,9 +1222,7 @@ "id": 24, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -1355,9 +1323,7 @@ "id": 14, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -1446,9 +1412,7 @@ "id": 15, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -1527,9 +1491,7 @@ "id": 16, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -1608,9 +1570,7 @@ "id": 17, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -1703,9 +1663,7 @@ "id": 26, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -1785,9 +1743,7 @@ "id": 27, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -1866,9 +1822,7 @@ "id": 28, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -1947,9 +1901,7 @@ "id": 29, "options": { "legend": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "displayMode": "table", "placement": "bottom", "showLegend": true, @@ -2034,9 +1986,7 @@ "justifyMode": "auto", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -2099,9 +2049,7 @@ "justifyMode": "auto", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -2160,9 +2108,7 @@ "justifyMode": "auto", "orientation": "auto", "reduceOptions": { - "calcs": [ - "lastNotNull" - ], + "calcs": ["lastNotNull"], "fields": "", "values": false }, @@ -2389,11 +2335,7 @@ "preload": false, "refresh": "30s", "schemaVersion": 39, - "tags": [ - "yucca", - "michael", - "prod" - ], + "tags": ["yucca", "michael", "prod"], "templating": { "list": [ { @@ -2445,4 +2387,4 @@ "title": "Yucca: Michael (restic gateway)", "uid": "yucca-michael", "version": 1 -} \ No newline at end of file +} diff --git a/packages/common/src/features.ts b/packages/common/src/features.ts new file mode 100644 index 00000000..0ea3ce0d --- /dev/null +++ b/packages/common/src/features.ts @@ -0,0 +1,41 @@ +export type FeatureFlagStage = 'experimental' | 'beta' | 'ga' | 'retired'; + +export interface FeatureFlagDefinition { + key: string; + default: boolean; + stage: FeatureFlagStage; + description: string; + since: string; +} + +export const FeatureFlags = { + connectionRestic: { + key: 'connection-restic', + default: false, + stage: 'experimental', + description: 'Use raw restic (create restic connections / self-service tokens)', + since: '0.22.0', + }, +} as const satisfies Record; + +export type FeatureFlagKey = (typeof FeatureFlags)[keyof typeof FeatureFlags]['key']; + +export const featureFlagDefs = (): FeatureFlagDefinition[] => Object.values(FeatureFlags); + +export const featureFlagByKey = (key: string): FeatureFlagDefinition | undefined => + featureFlagDefs().find((flag) => flag.key === key); + +export const resolveFeatures = ( + overrides: ReadonlyArray<{ flag: string; value: boolean }>, +): Record => { + const features: Record = {}; + for (const flag of featureFlagDefs()) { + features[flag.key] = flag.default; + } + for (const override of overrides) { + if (override.flag in features) { + features[override.flag] = override.value; + } + } + return features; +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 5fd7ef31..b8795830 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1 +1 @@ -// eslint-disable-next-line unicorn/no-empty-file +export * from './features'; diff --git a/packages/yucca-admin-api/openapi-specs.json b/packages/yucca-admin-api/openapi-specs.json index e50b17bd..36d79f20 100644 --- a/packages/yucca-admin-api/openapi-specs.json +++ b/packages/yucca-admin-api/openapi-specs.json @@ -330,6 +330,113 @@ ] } }, + "/api/user/{id}/features": { + "get": { + "operationId": "getUserFeatures", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserFeaturesResponseDto" + } + } + } + } + }, + "tags": [ + "User" + ] + } + }, + "/api/user/{id}/features/{flag}": { + "put": { + "operationId": "setUserFeature", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "flag", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeatureOverrideSetRequestDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeatureOverrideDto" + } + } + } + } + }, + "tags": [ + "User" + ] + }, + "delete": { + "operationId": "clearUserFeature", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "flag", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": [ + "User" + ] + } + }, "/api/session/{id}": { "delete": { "operationId": "deleteSession", @@ -701,9 +808,9 @@ ] } }, - "/api/settings": { + "/api/features": { "get": { - "operationId": "listSettings", + "operationId": "listFeatures", "parameters": [], "responses": { "200": { @@ -711,23 +818,53 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SettingsListResponseDto" + "$ref": "#/components/schemas/FeatureFlagListResponseDto" } } } } }, "tags": [ - "Settings" + "Features" ] } }, - "/api/settings/{scope}": { - "put": { - "operationId": "setSettings", + "/api/features/{flag}/users": { + "get": { + "operationId": "listFeatureUsers", "parameters": [ { - "name": "scope", + "name": "flag", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FeatureUsersResponseDto" + } + } + } + } + }, + "tags": [ + "Features" + ] + } + }, + "/api/features/{flag}/enable-batch": { + "post": { + "operationId": "enableFeatureBatch", + "parameters": [ + { + "name": "flag", "required": true, "in": "path", "schema": { @@ -740,7 +877,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SettingsValueDto" + "$ref": "#/components/schemas/FeatureEnableBatchRequestDto" } } } @@ -751,35 +888,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SettingsEntryResponseDto" + "$ref": "#/components/schemas/FeatureEnableBatchResponseDto" } } } } }, "tags": [ - "Settings" - ] - }, - "delete": { - "operationId": "deleteSettings", - "parameters": [ - { - "name": "scope", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "" - } - }, - "tags": [ - "Settings" + "Features" ] } } @@ -951,6 +1067,69 @@ "items" ] }, + "FeatureOverrideDto": { + "type": "object", + "properties": { + "flag": { + "type": "string" + }, + "value": { + "type": "boolean" + }, + "setBy": { + "type": "string" + }, + "reason": { + "type": "object", + "nullable": true + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "flag", + "value", + "setBy", + "updatedAt" + ] + }, + "UserFeaturesResponseDto": { + "type": "object", + "properties": { + "features": { + "type": "object", + "additionalProperties": { + "type": "boolean" + }, + "description": "Resolved flags: override, else registry default" + }, + "overrides": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FeatureOverrideDto" + } + } + }, + "required": [ + "features", + "overrides" + ] + }, + "FeatureOverrideSetRequestDto": { + "type": "object", + "properties": { + "value": { + "type": "boolean" + }, + "reason": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, "RepositoryOwnerDto": { "type": "object", "properties": { @@ -1013,14 +1192,6 @@ "worm": { "type": "boolean" }, - "siteCode": { - "type": "string", - "description": "Stable internal site code" - }, - "storageClusterCode": { - "type": "string", - "description": "Stable, globally unique internal storage cluster code" - }, "user": { "$ref": "#/components/schemas/RepositoryOwnerDto" }, @@ -1032,8 +1203,6 @@ "id", "name", "worm", - "siteCode", - "storageClusterCode", "user", "metrics" ] @@ -1068,10 +1237,6 @@ }, "worm": { "type": "boolean" - }, - "site": { - "type": "string", - "description": "Internal site code to place the repository in; defaults to the topology default site" } }, "required": [ @@ -1252,61 +1417,123 @@ "count" ] }, - "SettingsValueDto": { + "FeatureFlagDefDto": { "type": "object", "properties": { - "restic_pack_size_mib": { - "type": "number" + "key": { + "type": "string" }, - "connections_math": { + "default": { + "type": "boolean", + "description": "Registry default when no override exists" + }, + "stage": { "type": "string", - "description": "Client-evaluated expression: integers, cores, min, max, + - * /" + "description": "experimental | beta | ga | retired" + }, + "description": { + "type": "string" + }, + "since": { + "type": "string" + }, + "overrides": { + "type": "number", + "description": "Number of users with an override for this flag" } - } + }, + "required": [ + "key", + "default", + "stage", + "description", + "since", + "overrides" + ] }, - "SettingsEntryDto": { + "FeatureFlagListResponseDto": { "type": "object", "properties": { - "scope": { - "type": "string", - "description": "'global', 'site:' or 'cluster:'" + "flags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FeatureFlagDefDto" + } + } + }, + "required": [ + "flags" + ] + }, + "FeatureUserDto": { + "type": "object", + "properties": { + "userId": { + "type": "string" + }, + "email": { + "type": "string" }, "value": { - "$ref": "#/components/schemas/SettingsValueDto" + "type": "boolean" + }, + "setBy": { + "type": "string" + }, + "reason": { + "type": "object", + "nullable": true }, "updatedAt": { "type": "string" } }, "required": [ - "scope", + "userId", + "email", "value", + "setBy", "updatedAt" ] }, - "SettingsListResponseDto": { + "FeatureUsersResponseDto": { "type": "object", "properties": { - "settings": { + "items": { "type": "array", "items": { - "$ref": "#/components/schemas/SettingsEntryDto" + "$ref": "#/components/schemas/FeatureUserDto" } } }, "required": [ - "settings" + "items" ] }, - "SettingsEntryResponseDto": { + "FeatureEnableBatchRequestDto": { "type": "object", "properties": { - "entry": { - "$ref": "#/components/schemas/SettingsEntryDto" + "count": { + "type": "number", + "description": "Enable for this many not-yet-overridden users, oldest first" } }, "required": [ - "entry" + "count" + ] + }, + "FeatureEnableBatchResponseDto": { + "type": "object", + "properties": { + "enabled": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FeatureUserDto" + } + } + }, + "required": [ + "enabled" ] } } diff --git a/packages/yucca-admin-api/src/app.module.ts b/packages/yucca-admin-api/src/app.module.ts index 5fc55cb4..ef443f84 100644 --- a/packages/yucca-admin-api/src/app.module.ts +++ b/packages/yucca-admin-api/src/app.module.ts @@ -6,6 +6,7 @@ import { KyselyModule } from 'nestjs-kysely'; import { createPublicKey } from 'node:crypto'; import { AllowlistController } from './controllers/allowlist.controller'; import { AuthController } from './controllers/auth.controller'; +import { FeaturesController } from './controllers/features.controller'; import { RepositoryController } from './controllers/repository.controller'; import { SessionController } from './controllers/session.controller'; import { SettingsController } from './controllers/settings.controller'; @@ -13,6 +14,7 @@ import { UserController } from './controllers/user.controller'; import { env } from './env'; import { AuthGuard } from './middleware/auth.guard'; import { DatabaseRepository } from './repositories/database.repository'; +import { FeatureFlagRepository } from './repositories/featureFlag.repository'; import { OidcRepository } from './repositories/oidc.repository'; import { RepositoryRepository } from './repositories/repository.repository'; import { SessionRepository } from './repositories/session.repository'; @@ -24,6 +26,7 @@ import { UserAllowlistRepository } from './repositories/userAllowlist.repository import { AllowlistService } from './services/allowlist.service'; import { AuthService } from './services/auth.service'; import { DatabaseService } from './services/database.service'; +import { FeaturesService } from './services/features.service'; import { RepositoryService } from './services/repository.service'; import { SessionService } from './services/session.service'; import { SettingsService } from './services/settings.service'; @@ -51,6 +54,7 @@ export const controllers = [ RepositoryController, AllowlistController, SettingsController, + FeaturesController, ]; export const providers = [ @@ -66,6 +70,7 @@ export const providers = [ SettingsRepository, StorageRepository, TopologyRepository, + FeatureFlagRepository, AllowlistService, AuthService, UserService, @@ -73,6 +78,7 @@ export const providers = [ SettingsService, TopologyService, RepositoryService, + FeaturesService, { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, { provide: APP_GUARD, useClass: AuthGuard }, ]; diff --git a/packages/yucca-admin-api/src/controllers/features.controller.ts b/packages/yucca-admin-api/src/controllers/features.controller.ts new file mode 100644 index 00000000..f62b0b74 --- /dev/null +++ b/packages/yucca-admin-api/src/controllers/features.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiOkResponse } from '@nestjs/swagger'; +import { AuthDto } from 'src/dto/auth.dto'; +import { + FeatureEnableBatchRequestDto, + FeatureEnableBatchResponseDto, + FeatureFlagListResponseDto, + FeatureUsersResponseDto, +} from 'src/dto/features.dto'; +import { Auth, AuthRoute } from 'src/middleware/auth.guard'; +import { FeaturesService } from 'src/services/features.service'; + +@Controller('/features') +export class FeaturesController { + constructor(private readonly features: FeaturesService) {} + + @Get() + @AuthRoute() + @ApiOkResponse({ type: FeatureFlagListResponseDto }) + listFeatures(): Promise { + return this.features.list(); + } + + @Get('/:flag/users') + @AuthRoute() + @ApiOkResponse({ type: FeatureUsersResponseDto }) + listFeatureUsers(@Param('flag') flag: string): Promise { + return this.features.listUsers(flag); + } + + @Post('/:flag/enable-batch') + @AuthRoute() + @ApiOkResponse({ type: FeatureEnableBatchResponseDto }) + enableFeatureBatch( + @Auth() auth: AuthDto, + @Param('flag') flag: string, + @Body() dto: FeatureEnableBatchRequestDto, + ): Promise { + return this.features.enableBatch(auth, flag, dto); + } +} diff --git a/packages/yucca-admin-api/src/controllers/user.controller.ts b/packages/yucca-admin-api/src/controllers/user.controller.ts index 72b28ef1..87145052 100644 --- a/packages/yucca-admin-api/src/controllers/user.controller.ts +++ b/packages/yucca-admin-api/src/controllers/user.controller.ts @@ -1,5 +1,7 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Put, Query } from '@nestjs/common'; import { ApiOkResponse } from '@nestjs/swagger'; +import { AuthDto } from 'src/dto/auth.dto'; +import { FeatureOverrideDto, FeatureOverrideSetRequestDto, UserFeaturesResponseDto } from 'src/dto/features.dto'; import { SessionListResponseDto } from 'src/dto/session.dto'; import { UserGetResponseDto, @@ -8,7 +10,8 @@ import { UserUpdateRequestDto, UserUpdateResponseDto, } from 'src/dto/user.dto'; -import { AuthRoute } from 'src/middleware/auth.guard'; +import { Auth, AuthRoute } from 'src/middleware/auth.guard'; +import { FeaturesService } from 'src/services/features.service'; import { SessionService } from 'src/services/session.service'; import { UserService } from 'src/services/user.service'; @@ -17,6 +20,7 @@ export class UserController { constructor( private readonly user: UserService, private readonly session: SessionService, + private readonly features: FeaturesService, ) {} @Get() @@ -60,4 +64,30 @@ export class UserController { deleteUserSessions(@Param('userId') userId: string): Promise { return this.session.deleteForUser(userId); } + + @Get('/:id/features') + @AuthRoute() + @ApiOkResponse({ type: UserFeaturesResponseDto }) + getUserFeatures(@Param('id') id: string): Promise { + return this.features.getForUser(id); + } + + @Put('/:id/features/:flag') + @AuthRoute() + @ApiOkResponse({ type: FeatureOverrideDto }) + setUserFeature( + @Auth() auth: AuthDto, + @Param('id') id: string, + @Param('flag') flag: string, + @Body() dto: FeatureOverrideSetRequestDto, + ): Promise { + return this.features.set(auth, id, flag, dto); + } + + @Delete('/:id/features/:flag') + @AuthRoute() + @HttpCode(HttpStatus.NO_CONTENT) + clearUserFeature(@Auth() auth: AuthDto, @Param('id') id: string, @Param('flag') flag: string): Promise { + return this.features.clear(auth, id, flag); + } } diff --git a/packages/yucca-admin-api/src/dto/features.dto.ts b/packages/yucca-admin-api/src/dto/features.dto.ts new file mode 100644 index 00000000..38298d20 --- /dev/null +++ b/packages/yucca-admin-api/src/dto/features.dto.ts @@ -0,0 +1,105 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class FeatureFlagDefDto { + @ApiProperty() + key!: string; + + @ApiProperty({ description: 'Registry default when no override exists' }) + default!: boolean; + + @ApiProperty({ description: 'experimental | beta | ga | retired' }) + stage!: string; + + @ApiProperty() + description!: string; + + @ApiProperty() + since!: string; + + @ApiProperty({ description: 'Number of users with an override for this flag' }) + overrides!: number; +} + +export class FeatureFlagListResponseDto { + @ApiProperty({ type: [FeatureFlagDefDto] }) + flags!: FeatureFlagDefDto[]; +} + +export class FeatureOverrideDto { + @ApiProperty() + flag!: string; + + @ApiProperty() + value!: boolean; + + @ApiProperty() + setBy!: string; + + @ApiProperty({ required: false, nullable: true }) + reason!: string | null; + + @ApiProperty({ type: 'string' }) + updatedAt!: Date; +} + +export class UserFeaturesResponseDto { + @ApiProperty({ + type: 'object', + additionalProperties: { type: 'boolean' }, + description: 'Resolved flags: override, else registry default', + }) + features!: Record; + + @ApiProperty({ type: [FeatureOverrideDto] }) + overrides!: FeatureOverrideDto[]; +} + +export class FeatureOverrideSetRequestDto { + @ApiProperty() + @IsBoolean() + value!: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + reason?: string; +} + +export class FeatureEnableBatchRequestDto { + @ApiProperty({ description: 'Enable for this many not-yet-overridden users, oldest first' }) + @IsInt() + @Min(1) + @Max(500) + count!: number; +} + +export class FeatureUserDto { + @ApiProperty() + userId!: string; + + @ApiProperty() + email!: string; + + @ApiProperty() + value!: boolean; + + @ApiProperty() + setBy!: string; + + @ApiProperty({ required: false, nullable: true }) + reason!: string | null; + + @ApiProperty({ type: 'string' }) + updatedAt!: Date; +} + +export class FeatureUsersResponseDto { + @ApiProperty({ type: [FeatureUserDto] }) + items!: FeatureUserDto[]; +} + +export class FeatureEnableBatchResponseDto { + @ApiProperty({ type: [FeatureUserDto] }) + enabled!: FeatureUserDto[]; +} diff --git a/packages/yucca-admin-api/src/repositories/featureFlag.repository.ts b/packages/yucca-admin-api/src/repositories/featureFlag.repository.ts new file mode 100644 index 00000000..aca390d6 --- /dev/null +++ b/packages/yucca-admin-api/src/repositories/featureFlag.repository.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely, sql } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DB } from 'src/schema'; + +@Injectable() +export class FeatureFlagRepository { + constructor(@InjectKysely() private db: Kysely) {} + + getByUser(userId: string) { + return this.db + .selectFrom('userFeatureFlagOverride') + .selectAll() + .where('userId', '=', userId) + .orderBy('flag', 'asc') + .execute(); + } + + upsert(userId: string, flag: string, value: boolean, setBy: string, reason?: string) { + return this.db + .insertInto('userFeatureFlagOverride') + .values({ userId, flag, value, setBy, reason: reason ?? null }) + .onConflict((oc) => + oc.columns(['userId', 'flag']).doUpdateSet({ value, setBy, reason: reason ?? null, updatedAt: new Date() }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + } + + async delete(userId: string, flag: string) { + await this.db + .deleteFrom('userFeatureFlagOverride') + .where('userId', '=', sql`${userId}::uuid`) + .where('flag', '=', flag) + .execute(); + } + + countsByFlag() { + return this.db + .selectFrom('userFeatureFlagOverride') + .select(['flag']) + .select((eb) => eb.fn.countAll().as('count')) + .groupBy('flag') + .execute(); + } + + listByFlag(flag: string) { + return this.db + .selectFrom('userFeatureFlagOverride') + .innerJoin('users', 'users.id', 'userFeatureFlagOverride.userId') + .where('flag', '=', flag) + .select([ + 'userFeatureFlagOverride.userId', + 'users.email', + 'userFeatureFlagOverride.value', + 'userFeatureFlagOverride.setBy', + 'userFeatureFlagOverride.reason', + 'userFeatureFlagOverride.updatedAt', + ]) + .orderBy('users.email', 'asc') + .execute(); + } + + usersWithoutOverride(flag: string, count: number) { + return this.db + .selectFrom('users') + .where('users.disabled', '=', false) + .where(({ not, exists, selectFrom }) => + not( + exists( + selectFrom('userFeatureFlagOverride') + .select('userFeatureFlagOverride.id') + .whereRef('userFeatureFlagOverride.userId', '=', 'users.id') + .where('userFeatureFlagOverride.flag', '=', flag), + ), + ), + ) + .select(['users.id', 'users.email']) + .orderBy('users.createdAt', 'asc') + .orderBy('users.id', 'asc') + .limit(count) + .execute(); + } +} diff --git a/packages/yucca-admin-api/src/services/features.service.ts b/packages/yucca-admin-api/src/services/features.service.ts new file mode 100644 index 00000000..df2da5b4 --- /dev/null +++ b/packages/yucca-admin-api/src/services/features.service.ts @@ -0,0 +1,88 @@ +import { featureFlagByKey, featureFlagDefs, resolveFeatures } from '@common/server'; +import { LoggerRepository, WideContextRepository } from '@common/server/otel'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AuthDto } from 'src/dto/auth.dto'; +import { + FeatureEnableBatchRequestDto, + FeatureEnableBatchResponseDto, + FeatureFlagListResponseDto, + FeatureOverrideSetRequestDto, + FeatureUsersResponseDto, + UserFeaturesResponseDto, +} from 'src/dto/features.dto'; +import { FeatureFlagRepository } from 'src/repositories/featureFlag.repository'; + +@Injectable() +export class FeaturesService { + constructor( + private readonly logger: LoggerRepository, + private readonly wideContext: WideContextRepository, + private readonly featureFlags: FeatureFlagRepository, + ) {} + + async list(): Promise { + const countRows = await this.featureFlags.countsByFlag(); + const counts = new Map(countRows.map((row) => [row.flag, Number(row.count)])); + return { + flags: featureFlagDefs().map((def) => ({ ...def, overrides: counts.get(def.key) ?? 0 })), + }; + } + + async getForUser(userId: string): Promise { + const overrides = await this.featureFlags.getByUser(userId); + return { + features: resolveFeatures(overrides), + overrides, + }; + } + + private assertKnownFlag(flag: string) { + if (!featureFlagByKey(flag)) { + throw new BadRequestException(`Unknown feature flag '${flag}'`); + } + } + + async set(auth: AuthDto, userId: string, flag: string, dto: FeatureOverrideSetRequestDto) { + this.assertKnownFlag(flag); + const override = await this.featureFlags.upsert(userId, flag, dto.value, auth.sub, dto.reason); + this.wideContext.assignContext({ featureFlag: { flag, userId, value: dto.value, setBy: auth.sub } }); + this.logger.info(`Feature '${flag}' set to ${dto.value} for user ${userId} by ${auth.sub}`); + return override; + } + + async clear(auth: AuthDto, userId: string, flag: string): Promise { + this.assertKnownFlag(flag); + await this.featureFlags.delete(userId, flag); + this.logger.info(`Feature '${flag}' override cleared for user ${userId} by ${auth.sub}`); + } + + async listUsers(flag: string): Promise { + this.assertKnownFlag(flag); + return { items: await this.featureFlags.listByFlag(flag) }; + } + + async enableBatch( + auth: AuthDto, + flag: string, + dto: FeatureEnableBatchRequestDto, + ): Promise { + this.assertKnownFlag(flag); + const users = await this.featureFlags.usersWithoutOverride(flag, dto.count); + + const enabled = []; + for (const user of users) { + const override = await this.featureFlags.upsert(user.id, flag, true, auth.sub, 'enable-batch'); + enabled.push({ + userId: user.id, + email: user.email, + value: override.value, + setBy: override.setBy, + reason: override.reason, + updatedAt: override.updatedAt, + }); + } + + this.logger.info(`Feature '${flag}' batch-enabled for ${enabled.length} users by ${auth.sub}`); + return { enabled }; + } +} diff --git a/packages/yucca-admin-api/test/features.integration-spec.ts b/packages/yucca-admin-api/test/features.integration-spec.ts new file mode 100644 index 00000000..ec0aa99f --- /dev/null +++ b/packages/yucca-admin-api/test/features.integration-spec.ts @@ -0,0 +1,143 @@ +import { MetricService } from '@common/server/otel'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { OidcRepository } from 'src/repositories/oidc.repository'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { controllers, imports, providers } from '../src/app.module'; +import { newMetricServiceMock } from './mocks'; +import { testUtils } from './testUtils'; + +const authCookie = ['yucca-admin-sub=admin', 'yucca-admin-access-token=token']; + +describe('FeaturesController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports, + controllers, + providers: [MetricService, ...providers], + }) + .overrideProvider(MetricService) + .useValue(newMetricServiceMock()) + .overrideProvider(OidcRepository) + .useValue({ onModuleInit: jest.fn(), fetchUserInfo: jest.fn().mockResolvedValue({ sub: 'admin' }) }) + .compile(); + + app = moduleFixture.createNestApplication(); + app.setGlobalPrefix('/api'); + app.useGlobalPipes(new ValidationPipe()); + await app.init(); + + await testUtils.resetDatabase(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('GET /features', () => { + it('requires authentication', async () => { + await request(app.getHttpServer()).get('/api/features').expect(401); + }); + + it('lists the registry with override counts', async () => { + const user = await testUtils.createUser(); + await request(app.getHttpServer()) + .put(`/api/user/${user.id}/features/connection-restic`) + .set('Cookie', authCookie) + .send({ value: true, reason: 'testing' }) + .expect(200); + + const { body } = await request(app.getHttpServer()).get('/api/features').set('Cookie', authCookie).expect(200); + + const flag = body.flags.find((f: { key: string }) => f.key === 'connection-restic'); + expect(flag).toMatchObject({ key: 'connection-restic', default: false, stage: 'experimental', overrides: 1 }); + }); + }); + + describe('PUT/DELETE /user/:id/features/:flag', () => { + it('sets, resolves, and clears an override', async () => { + const user = await testUtils.createUser(); + + await request(app.getHttpServer()) + .put(`/api/user/${user.id}/features/connection-restic`) + .set('Cookie', authCookie) + .send({ value: true, reason: 'beta cohort 1' }) + .expect(200); + + let { body } = await request(app.getHttpServer()) + .get(`/api/user/${user.id}/features`) + .set('Cookie', authCookie) + .expect(200); + expect(body.features).toEqual({ 'connection-restic': true }); + expect(body.overrides).toEqual([ + expect.objectContaining({ flag: 'connection-restic', value: true, setBy: 'admin', reason: 'beta cohort 1' }), + ]); + + await request(app.getHttpServer()) + .delete(`/api/user/${user.id}/features/connection-restic`) + .set('Cookie', authCookie) + .expect(204); + + ({ body } = await request(app.getHttpServer()) + .get(`/api/user/${user.id}/features`) + .set('Cookie', authCookie) + .expect(200)); + expect(body.features).toEqual({ 'connection-restic': false }); + expect(body.overrides).toEqual([]); + }); + + it('rejects unknown flags', async () => { + const user = await testUtils.createUser(); + await request(app.getHttpServer()) + .put(`/api/user/${user.id}/features/not-a-flag`) + .set('Cookie', authCookie) + .send({ value: true }) + .expect(400); + }); + }); + + describe('POST /features/:flag/enable-batch', () => { + it('enables the oldest users without an override first', async () => { + const first = await testUtils.createUser({ name: 'first', createdAt: new Date('2026-01-01T00:00:00Z') }); + const second = await testUtils.createUser({ name: 'second', createdAt: new Date('2026-02-01T00:00:00Z') }); + const third = await testUtils.createUser({ name: 'third', createdAt: new Date('2026-03-01T00:00:00Z') }); + + const { body } = await request(app.getHttpServer()) + .post('/api/features/connection-restic/enable-batch') + .set('Cookie', authCookie) + .send({ count: 2 }) + .expect(201); + + expect(body.enabled).toHaveLength(2); + expect(body.enabled.map((e: { userId: string }) => e.userId)).toEqual([first.id, second.id]); + + const { body: second_run } = await request(app.getHttpServer()) + .post('/api/features/connection-restic/enable-batch') + .set('Cookie', authCookie) + .send({ count: 2 }) + .expect(201); + expect(second_run.enabled.map((e: { userId: string }) => e.userId)).toEqual([third.id]); + }); + + it('lists users with overrides for a flag', async () => { + const user = await testUtils.createUser(); + await request(app.getHttpServer()) + .put(`/api/user/${user.id}/features/connection-restic`) + .set('Cookie', authCookie) + .send({ value: false, reason: 'opt-out' }) + .expect(200); + + const { body } = await request(app.getHttpServer()) + .get('/api/features/connection-restic/users') + .set('Cookie', authCookie) + .expect(200); + + expect(body.items).toEqual([ + expect.objectContaining({ userId: user.id, email: user.email, value: false, reason: 'opt-out' }), + ]); + }); + }); +}); diff --git a/packages/yucca-admin-api/test/testUtils.ts b/packages/yucca-admin-api/test/testUtils.ts index 3dbb3264..785add96 100644 --- a/packages/yucca-admin-api/test/testUtils.ts +++ b/packages/yucca-admin-api/test/testUtils.ts @@ -16,6 +16,7 @@ function getDb() { export const testUtils = { resetDatabase: async () => { const db = getDb(); + await db.deleteFrom('userFeatureFlagOverride').execute(); await db.deleteFrom('repositories').execute(); await db.deleteFrom('sessions').execute(); await db.deleteFrom('users').execute(); @@ -55,10 +56,17 @@ export const testUtils = { email, sub, disabled = false, - }: Partial<{ name: string; email: string; sub: string; disabled: boolean }> = {}) => { + createdAt, + }: Partial<{ name: string; email: string; sub: string; disabled: boolean; createdAt: Date }> = {}) => { return getDb() .insertInto('users') - .values({ name, email: email ?? `${randomUUID()}@example.test`, sub: sub ?? randomUUID(), disabled }) + .values({ + name, + email: email ?? `${randomUUID()}@example.test`, + sub: sub ?? randomUUID(), + disabled, + ...(createdAt ? { createdAt } : {}), + }) .returningAll() .executeTakeFirstOrThrow(); }, diff --git a/packages/yucca-admin-api/test/user.integration-spec.ts b/packages/yucca-admin-api/test/user.integration-spec.ts index afc0b95e..c0985def 100644 --- a/packages/yucca-admin-api/test/user.integration-spec.ts +++ b/packages/yucca-admin-api/test/user.integration-spec.ts @@ -86,7 +86,14 @@ describe('UserController (e2e)', () => { .expect(200); expect(body).toEqual({ - user: { id: user.id, sub: user.sub, name: 'carol', email: user.email, disabled: false }, + user: { + id: user.id, + sub: user.sub, + name: 'carol', + email: user.email, + disabled: false, + createdAt: expect.any(String), + }, }); }); }); diff --git a/packages/yucca-api-client/openapi-specs.json b/packages/yucca-api-client/openapi-specs.json index 3270b431..cc11f1e8 100644 --- a/packages/yucca-api-client/openapi-specs.json +++ b/packages/yucca-api-client/openapi-specs.json @@ -482,13 +482,20 @@ }, "sessionId": { "type": "string" + }, + "features": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } } }, "required": [ "id", "name", "email", - "sessionId" + "sessionId", + "features" ] }, "MetaConfigDto": { diff --git a/packages/yucca-api-client/src/fetch-client.ts b/packages/yucca-api-client/src/fetch-client.ts index 86a2187f..5eb5ac5e 100644 --- a/packages/yucca-api-client/src/fetch-client.ts +++ b/packages/yucca-api-client/src/fetch-client.ts @@ -19,6 +19,9 @@ export type AuthDto = { name: string; email: string; sessionId: string; + features: { + [key: string]: boolean; + }; }; export type MetaConfigDto = { restic_pack_size_mib: number; diff --git a/packages/yucca-api/src/dto/auth.dto.ts b/packages/yucca-api/src/dto/auth.dto.ts index b3fc3ae1..3c8dd47f 100644 --- a/packages/yucca-api/src/dto/auth.dto.ts +++ b/packages/yucca-api/src/dto/auth.dto.ts @@ -12,4 +12,7 @@ export class AuthDto { @ApiProperty() sessionId!: string; + + @ApiProperty({ type: 'object', additionalProperties: { type: 'boolean' } }) + features!: Record; } diff --git a/packages/yucca-api/src/enum.ts b/packages/yucca-api/src/enum.ts index cf8ddcce..af963bbc 100644 --- a/packages/yucca-api/src/enum.ts +++ b/packages/yucca-api/src/enum.ts @@ -7,6 +7,7 @@ export enum CookieName { export enum MetadataKey { Auth = 'AUTH', + Feature = 'FEATURE', } export enum DatabaseLock { diff --git a/packages/yucca-api/src/middleware/auth.guard.ts b/packages/yucca-api/src/middleware/auth.guard.ts index 216e7600..93130921 100644 --- a/packages/yucca-api/src/middleware/auth.guard.ts +++ b/packages/yucca-api/src/middleware/auth.guard.ts @@ -1,6 +1,8 @@ +import { FeatureFlagKey } from '@common/server'; import { CanActivate, ExecutionContext, + ForbiddenException, Injectable, Scope, SetMetadata, @@ -17,6 +19,10 @@ export const AuthRoute = (options = {}): MethodDecorator => { return applyDecorators(SetMetadata(MetadataKey.Auth, options)); }; +export const RequireFeature = (flag: FeatureFlagKey): MethodDecorator => { + return applyDecorators(SetMetadata(MetadataKey.Feature, flag)); +}; + export interface AuthRequest extends Request { auth: AuthDto; } @@ -41,6 +47,12 @@ export class AuthGuard implements CanActivate { const request = context.switchToHttp().getRequest(); request.auth = await this.service.authenticate(request.headers); + + const feature = this.reflector.getAllAndOverride(MetadataKey.Feature, targets); + if (feature && !request.auth.features[feature]) { + throw new ForbiddenException(`Feature '${feature}' is not enabled for this account`); + } + return true; } } diff --git a/packages/yucca-api/src/repositories/user.repository.ts b/packages/yucca-api/src/repositories/user.repository.ts index d1ed5f17..2779de36 100644 --- a/packages/yucca-api/src/repositories/user.repository.ts +++ b/packages/yucca-api/src/repositories/user.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, sql, Updateable } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { DB } from 'src/schema'; import { UserTable } from 'src/schema/tables/user.table'; @@ -22,7 +23,21 @@ export class UserRepository { .where('accessToken', '=', accessToken) .innerJoin('users', 'users.id', 'sessions.userId') .where('users.disabled', '=', false) - .select(['users.id', 'users.sub', 'users.name', 'users.email', 'sessions.id as sessionId']) + .select((eb) => [ + 'users.id', + 'users.sub', + 'users.name', + 'users.email', + 'sessions.id as sessionId', + jsonArrayFrom( + eb + .selectFrom('userFeatureFlagOverride') + .select(['flag', 'value']) + .whereRef('userFeatureFlagOverride.userId', '=', 'users.id'), + ) + .$castTo<{ flag: string; value: boolean }[]>() + .as('featureOverrides'), + ]) .executeTakeFirst(); } diff --git a/packages/yucca-api/src/schema/index.ts b/packages/yucca-api/src/schema/index.ts index 95de2cb3..3f04b66a 100644 --- a/packages/yucca-api/src/schema/index.ts +++ b/packages/yucca-api/src/schema/index.ts @@ -8,6 +8,7 @@ import { SessionTable } from './tables/session.table'; import { SettingsTable } from './tables/settings.table'; import { UserTable } from './tables/user.table'; import { UserAllowlistTable } from './tables/userAllowlist.table'; +import { UserFeatureFlagOverrideTable } from './tables/userFeatureFlagOverride.table'; @Database({ name: 'yucca' }) export class ImmichDatabase { @@ -21,6 +22,7 @@ export class ImmichDatabase { RepositoryMeterHistoryTable, UserAllowlistTable, SettingsTable, + UserFeatureFlagOverrideTable, ]; functions = []; @@ -38,4 +40,5 @@ export interface DB { repositoryMeterHistory: RepositoryMeterHistoryTable; userAllowlist: UserAllowlistTable; settings: SettingsTable; + userFeatureFlagOverride: UserFeatureFlagOverrideTable; } diff --git a/packages/yucca-api/src/schema/migrations/20260727190000-AddUserFeatureFlags.ts b/packages/yucca-api/src/schema/migrations/20260727190000-AddUserFeatureFlags.ts new file mode 100644 index 00000000..a22e782e --- /dev/null +++ b/packages/yucca-api/src/schema/migrations/20260727190000-AddUserFeatureFlags.ts @@ -0,0 +1,25 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "users" ADD "createdAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`UPDATE "users" SET "createdAt" = '2026-01-01T00:00:00Z';`.execute(db); + + await sql`CREATE TABLE "userFeatureFlagOverride" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "userId" uuid NOT NULL, + "flag" character varying NOT NULL, + "value" boolean NOT NULL, + "setBy" character varying NOT NULL, + "reason" character varying, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "userFeatureFlagOverride_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "userFeatureFlagOverride_userId_flag_uq" UNIQUE ("userId", "flag"), + CONSTRAINT "userFeatureFlagOverride_pkey" PRIMARY KEY ("id") +);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE "userFeatureFlagOverride";`.execute(db); + await sql`ALTER TABLE "users" DROP COLUMN "createdAt";`.execute(db); +} diff --git a/packages/yucca-api/src/schema/tables/user.table.ts b/packages/yucca-api/src/schema/tables/user.table.ts index ba3ef35a..df14c0b9 100644 --- a/packages/yucca-api/src/schema/tables/user.table.ts +++ b/packages/yucca-api/src/schema/tables/user.table.ts @@ -16,4 +16,7 @@ export class UserTable { @Column({ type: 'boolean', default: () => 'false' }) disabled!: Generated; + + @Column({ type: 'timestamp with time zone', default: () => 'now()' }) + createdAt!: Generated; } diff --git a/packages/yucca-api/src/schema/tables/userFeatureFlagOverride.table.ts b/packages/yucca-api/src/schema/tables/userFeatureFlagOverride.table.ts new file mode 100644 index 00000000..22824a16 --- /dev/null +++ b/packages/yucca-api/src/schema/tables/userFeatureFlagOverride.table.ts @@ -0,0 +1,30 @@ +import { Column, ForeignKeyColumn, type Generated, Table, Unique } from '@immich/sql-tools'; +import { UserTable } from './user.table'; + +@Table({ name: 'userFeatureFlagOverride' }) +@Unique({ columns: ['userId', 'flag'] }) +export class UserFeatureFlagOverrideTable { + @Column({ primary: true, type: 'uuid', default: () => 'gen_random_uuid()' }) + id!: Generated; + + @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + userId!: string; + + @Column() + flag!: string; + + @Column({ type: 'boolean' }) + value!: boolean; + + @Column() + setBy!: string; + + @Column({ nullable: true }) + reason!: string | null; + + @Column({ type: 'timestamp with time zone', default: () => 'now()' }) + createdAt!: Generated; + + @Column({ type: 'timestamp with time zone', default: () => 'now()' }) + updatedAt!: Generated; +} diff --git a/packages/yucca-api/src/services/auth.service.spec.ts b/packages/yucca-api/src/services/auth.service.spec.ts index 66d799b5..5fff27f0 100644 --- a/packages/yucca-api/src/services/auth.service.spec.ts +++ b/packages/yucca-api/src/services/auth.service.spec.ts @@ -62,14 +62,38 @@ describe(AuthService.name, () => { }); it('should return user if one is found', async () => { - mocks.user.getByAccessToken.mockResolvedValue(mockUser); + mocks.user.getByAccessToken.mockResolvedValue({ ...mockUser, featureOverrides: [] }); await expect( sut.authenticate({ cookie: 'yucca-access-token=my-token', }), - ).resolves.toBe(mockUser); + ).resolves.toEqual({ ...mockUser, features: { 'connection-restic': false } }); expect(mocks.wideContext.addContext).toHaveBeenCalledWith('customerId', mockUser.id); }); + + it('should resolve feature overrides over registry defaults', async () => { + mocks.user.getByAccessToken.mockResolvedValue({ + ...mockUser, + featureOverrides: [{ flag: 'connection-restic', value: true }], + }); + await expect( + sut.authenticate({ + cookie: 'yucca-access-token=my-token', + }), + ).resolves.toEqual(expect.objectContaining({ features: { 'connection-restic': true } })); + }); + + it('should ignore overrides for flags not in the registry', async () => { + mocks.user.getByAccessToken.mockResolvedValue({ + ...mockUser, + featureOverrides: [{ flag: 'no-longer-a-flag', value: true }], + }); + await expect( + sut.authenticate({ + cookie: 'yucca-access-token=my-token', + }), + ).resolves.toEqual(expect.objectContaining({ features: { 'connection-restic': false } })); + }); }); describe('logout', () => { diff --git a/packages/yucca-api/src/services/auth.service.ts b/packages/yucca-api/src/services/auth.service.ts index 1872ee55..6511a848 100644 --- a/packages/yucca-api/src/services/auth.service.ts +++ b/packages/yucca-api/src/services/auth.service.ts @@ -1,3 +1,4 @@ +import { resolveFeatures } from '@common/server'; import { LoggerRepository, WideContextRepository } from '@common/server/otel'; import { Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'; import { parse } from 'cookie'; @@ -36,14 +37,15 @@ export class AuthService { throw new UnauthorizedException(`Missing ${CookieName.AccessToken} cookie`); } - const user = await this.user.getByAccessToken(accessToken); - if (!user) { + const row = await this.user.getByAccessToken(accessToken); + if (!row) { throw new UnauthorizedException(`Invalid access token`); } - this.wideContext.addContext('customerId', user.id); + this.wideContext.addContext('customerId', row.id); - return user; + const { featureOverrides, ...user } = row; + return { ...user, features: resolveFeatures(featureOverrides) }; } async logout(auth: AuthDto): Promise { diff --git a/packages/yucca-api/src/utils/exceptions.ts b/packages/yucca-api/src/utils/exceptions.ts index ecd40a6e..f9e86378 100644 --- a/packages/yucca-api/src/utils/exceptions.ts +++ b/packages/yucca-api/src/utils/exceptions.ts @@ -5,3 +5,9 @@ export class EmailNotAllowedException extends ForbiddenException { super('Email is not allowed during the beta'); } } + +export class FeatureNotEnabledException extends ForbiddenException { + constructor(feature: string) { + super(`Feature '${feature}' is not enabled for this account`); + } +} diff --git a/packages/yucca-api/test/auth.integration-spec.ts b/packages/yucca-api/test/auth.integration-spec.ts index efb0c161..fe98a2e7 100644 --- a/packages/yucca-api/test/auth.integration-spec.ts +++ b/packages/yucca-api/test/auth.integration-spec.ts @@ -54,9 +54,21 @@ describe('AuthController (e2e)', () => { name: user.name, email: user.email, sessionId: session.id, + features: { 'connection-restic': false }, }); }); + it('reflects feature overrides in the auth response', async () => { + await testUtils.setFeatureOverride(user.id, 'connection-restic', true); + + const { body } = await request(app.getHttpServer()) + .get('/api/auth') + .set('Cookie', `yucca-access-token=${session.accessToken}`) + .expect(200); + + expect(body.features).toEqual({ 'connection-restic': true }); + }); + it('rejects a disabled user with an existing session', async () => { await testUtils.disableUser(user.id); diff --git a/packages/yucca-api/test/testUtils.ts b/packages/yucca-api/test/testUtils.ts index 0032c11d..ca4115e3 100644 --- a/packages/yucca-api/test/testUtils.ts +++ b/packages/yucca-api/test/testUtils.ts @@ -19,6 +19,7 @@ function getDb() { export const testUtils = { resetDatabase: async () => { const db = getDb(); + await db.deleteFrom('userFeatureFlagOverride').execute(); await db.deleteFrom('repositories').execute(); await db.deleteFrom('sessions').execute(); await db.deleteFrom('users').execute(); @@ -70,6 +71,15 @@ export const testUtils = { }; }, + setFeatureOverride: (userId: string, flag: string, value: boolean, setBy = 'test-admin') => { + return getDb() + .insertInto('userFeatureFlagOverride') + .values({ userId, flag, value, setBy }) + .onConflict((oc) => oc.columns(['userId', 'flag']).doUpdateSet({ value, updatedAt: new Date() })) + .returningAll() + .executeTakeFirstOrThrow(); + }, + getUserBySub: (sub: string) => { const db = getDb(); const userRepository = new UserRepository(db); diff --git a/packages/yuctl/internal/adminapi/features.go b/packages/yuctl/internal/adminapi/features.go new file mode 100644 index 00000000..74520e37 --- /dev/null +++ b/packages/yuctl/internal/adminapi/features.go @@ -0,0 +1,104 @@ +package adminapi + +import ( + "context" + "net/url" +) + +// FeatureFlag mirrors the admin-api FeatureFlagDefDto: a registry entry plus +// its override count. +type FeatureFlag struct { + Key string `json:"key"` + Default bool `json:"default"` + Stage string `json:"stage"` + Description string `json:"description"` + Since string `json:"since"` + Overrides int `json:"overrides"` +} + +// FeatureOverride mirrors FeatureOverrideDto. +type FeatureOverride struct { + Flag string `json:"flag"` + Value bool `json:"value"` + SetBy string `json:"setBy"` + Reason *string `json:"reason"` + UpdatedAt string `json:"updatedAt"` +} + +// UserFeatures mirrors UserFeaturesResponseDto. +type UserFeatures struct { + Features map[string]bool `json:"features"` + Overrides []FeatureOverride `json:"overrides"` +} + +// FeatureUser mirrors FeatureUserDto. +type FeatureUser struct { + UserID string `json:"userId"` + Email string `json:"email"` + Value bool `json:"value"` + SetBy string `json:"setBy"` + Reason *string `json:"reason"` + UpdatedAt string `json:"updatedAt"` +} + +// ListFeatures returns the flag registry with override counts. +func (c *Client) ListFeatures(ctx context.Context) ([]FeatureFlag, error) { + var out struct { + Flags []FeatureFlag `json:"flags"` + } + if err := c.getJSON(ctx, "/api/features", nil, &out); err != nil { + return nil, err + } + return out.Flags, nil +} + +// GetUserFeatures returns a user's resolved flags and raw overrides. +func (c *Client) GetUserFeatures(ctx context.Context, userID string) (*UserFeatures, error) { + var out UserFeatures + if err := c.getJSON(ctx, "/api/user/"+url.PathEscape(userID)+"/features", nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// SetUserFeature sets a per-user override for a flag. +func (c *Client) SetUserFeature(ctx context.Context, userID, flag string, value bool, reason string) (*FeatureOverride, error) { + body := map[string]any{"value": value} + if reason != "" { + body["reason"] = reason + } + var out FeatureOverride + path := "/api/user/" + url.PathEscape(userID) + "/features/" + url.PathEscape(flag) + if err := c.putJSON(ctx, path, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ClearUserFeature removes a per-user override (reverting to the registry default). +func (c *Client) ClearUserFeature(ctx context.Context, userID, flag string) error { + return c.deleteReq(ctx, "/api/user/"+url.PathEscape(userID)+"/features/"+url.PathEscape(flag)) +} + +// ListFeatureUsers returns the users holding an override for a flag. +func (c *Client) ListFeatureUsers(ctx context.Context, flag string) ([]FeatureUser, error) { + var out struct { + Items []FeatureUser `json:"items"` + } + if err := c.getJSON(ctx, "/api/features/"+url.PathEscape(flag)+"/users", nil, &out); err != nil { + return nil, err + } + return out.Items, nil +} + +// EnableFeatureBatch enables a flag for the oldest count users without an +// override yet. +func (c *Client) EnableFeatureBatch(ctx context.Context, flag string, count int) ([]FeatureUser, error) { + var out struct { + Enabled []FeatureUser `json:"enabled"` + } + if err := c.postJSON(ctx, "/api/features/"+url.PathEscape(flag)+"/enable-batch", map[string]any{"count": count}, &out); err != nil { + return nil, err + } + return out.Enabled, nil +} diff --git a/packages/yuctl/internal/adminapi/http.go b/packages/yuctl/internal/adminapi/http.go new file mode 100644 index 00000000..bd21e166 --- /dev/null +++ b/packages/yuctl/internal/adminapi/http.go @@ -0,0 +1,75 @@ +package adminapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +// getJSON GETs path (with optional query) and decodes the JSON response. +func (c *Client) getJSON(ctx context.Context, path string, query url.Values, out any) error { + u := c.baseURL + path + if enc := query.Encode(); enc != "" { + u += "?" + enc + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + c.setAuth(req) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("GET %s: %w", u, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("admin-api rejected the session token (status %d) — run `yuctl login --reauth`", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET %s: status %d", u, resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("parse %s response: %w", u, err) + } + return nil +} + +// putJSON PUTs a JSON body to path and decodes the JSON response into out +// (out may be nil). +func (c *Client) putJSON(ctx context.Context, path string, body, out any) error { + u := c.baseURL + path + b, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + c.setAuth(req) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("PUT %s: %w", u, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("admin-api rejected the session token (status %d) — run `yuctl login --reauth`", resp.StatusCode) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("PUT %s: status %d", u, resp.StatusCode) + } + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("parse %s response: %w", u, err) + } + } + return nil +} diff --git a/packages/yuctl/internal/cli/features.go b/packages/yuctl/internal/cli/features.go new file mode 100644 index 00000000..9c5a00ce --- /dev/null +++ b/packages/yuctl/internal/cli/features.go @@ -0,0 +1,118 @@ +package cli + +import ( + "fmt" + "strconv" + "text/tabwriter" + + "github.com/spf13/cobra" + + "yuctl/internal/adminapi" +) + +// newFeaturesCmd builds the `features` subtree: the feature-flag registry and +// fleet-wide operations (per-user set/clear lives under `users features`). +func newFeaturesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "features", + Short: "Feature-flag registry and batch enrollment", + } + cmd.AddCommand(newFeaturesListCmd()) + cmd.AddCommand(newFeaturesUsersCmd()) + cmd.AddCommand(newFeaturesEnableBatchCmd()) + return cmd +} + +func printFeatureUsers(cmd *cobra.Command, items []adminapi.FeatureUser) { + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0) + fmt.Fprintln(w, "EMAIL\tVALUE\tSET BY\tREASON\tUPDATED") + for _, u := range items { + reason := "" + if u.Reason != nil { + reason = *u.Reason + } + fmt.Fprintf(w, "%s\t%t\t%s\t%s\t%s\n", u.Email, u.Value, u.SetBy, reason, u.UpdatedAt) + } + w.Flush() +} + +func newFeaturesListCmd() *cobra.Command { + flags := &adminFlags{} + c := &cobra.Command{ + Use: "list", + Short: "List the feature-flag registry with override counts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, _, err := flags.allowlistClient(cmd) + if err != nil { + return err + } + featureFlags, err := client.ListFeatures(cmd.Context()) + if err != nil { + return err + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0) + fmt.Fprintln(w, "FLAG\tDEFAULT\tSTAGE\tOVERRIDES\tSINCE\tDESCRIPTION") + for _, f := range featureFlags { + fmt.Fprintf(w, "%s\t%t\t%s\t%d\t%s\t%s\n", f.Key, f.Default, f.Stage, f.Overrides, f.Since, f.Description) + } + w.Flush() + return nil + }, + } + flags.register(c) + return c +} + +func newFeaturesUsersCmd() *cobra.Command { + flags := &adminFlags{} + c := &cobra.Command{ + Use: "users ", + Short: "List users holding an override for a flag", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := flags.allowlistClient(cmd) + if err != nil { + return err + } + items, err := client.ListFeatureUsers(cmd.Context(), args[0]) + if err != nil { + return err + } + printFeatureUsers(cmd, items) + fmt.Fprintf(cmd.ErrOrStderr(), "\n%d override(s) for %s\n", len(items), args[0]) + return nil + }, + } + flags.register(c) + return c +} + +func newFeaturesEnableBatchCmd() *cobra.Command { + flags := &adminFlags{} + c := &cobra.Command{ + Use: "enable-batch ", + Short: "Enable a flag for the oldest users without an override", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + count, err := strconv.Atoi(args[1]) + if err != nil || count < 1 { + return fmt.Errorf("count must be a positive integer") + } + client, _, err := flags.allowlistClient(cmd) + if err != nil { + return err + } + enabled, err := client.EnableFeatureBatch(cmd.Context(), args[0], count) + if err != nil { + return err + } + printFeatureUsers(cmd, enabled) + fmt.Fprintf(cmd.ErrOrStderr(), "\nenabled %s for %d user(s)\n", args[0], len(enabled)) + return nil + }, + } + flags.register(c) + return c +} diff --git a/packages/yuctl/internal/cli/root.go b/packages/yuctl/internal/cli/root.go index 1298a313..37574cde 100644 --- a/packages/yuctl/internal/cli/root.go +++ b/packages/yuctl/internal/cli/root.go @@ -50,6 +50,7 @@ func NewRootCmd() *cobra.Command { newInfraCmd(), newUsersCmd(), newConfigCmd(), + newFeaturesCmd(), newToolsCmd(), ) return root diff --git a/packages/yuctl/internal/cli/users.go b/packages/yuctl/internal/cli/users.go index db0ed3fc..b94c49d1 100644 --- a/packages/yuctl/internal/cli/users.go +++ b/packages/yuctl/internal/cli/users.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "net/url" "os" @@ -22,9 +23,137 @@ func newUsersCmd() *cobra.Command { cmd.AddCommand(newUsersListCmd()) cmd.AddCommand(newUsersAllowlistCmd()) cmd.AddCommand(newUsersViewDashboardCmd()) + cmd.AddCommand(newUsersFeaturesCmd()) return cmd } +// newUsersFeaturesCmd builds `users features`: per-user feature-flag overrides. +func newUsersFeaturesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "features", + Short: "Per-user feature-flag overrides", + } + cmd.AddCommand(newUsersFeaturesListCmd()) + cmd.AddCommand(newUsersFeaturesSetCmd()) + cmd.AddCommand(newUsersFeaturesClearCmd()) + return cmd +} + +func newUsersFeaturesListCmd() *cobra.Command { + flags := &adminFlags{} + c := &cobra.Command{ + Use: "list ", + Short: "Show a user's resolved feature flags and overrides", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, _, err := flags.allowlistClient(cmd) + if err != nil { + return err + } + userID, err := resolveUserID(ctx, client, args[0]) + if err != nil { + return err + } + features, err := client.GetUserFeatures(ctx, userID) + if err != nil { + return err + } + + overridden := map[string]adminapi.FeatureOverride{} + for _, o := range features.Overrides { + overridden[o.Flag] = o + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0) + fmt.Fprintln(w, "FLAG\tVALUE\tSOURCE\tSET BY\tREASON") + for flag, value := range features.Features { + if o, ok := overridden[flag]; ok { + reason := "" + if o.Reason != nil { + reason = *o.Reason + } + fmt.Fprintf(w, "%s\t%t\toverride\t%s\t%s\n", flag, value, o.SetBy, reason) + } else { + fmt.Fprintf(w, "%s\t%t\tdefault\t\t\n", flag, value) + } + } + w.Flush() + return nil + }, + } + flags.register(c) + return c +} + +func newUsersFeaturesSetCmd() *cobra.Command { + flags := &adminFlags{} + var reason string + c := &cobra.Command{ + Use: "set on|off", + Short: "Set a per-user feature-flag override", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + var value bool + switch args[2] { + case "on", "true": + value = true + case "off", "false": + value = false + default: + return fmt.Errorf("value must be on|off, got %q", args[2]) + } + + ctx := cmd.Context() + client, _, err := flags.allowlistClient(cmd) + if err != nil { + return err + } + userID, err := resolveUserID(ctx, client, args[0]) + if err != nil { + return err + } + if _, err := client.SetUserFeature(ctx, userID, args[1], value, reason); err != nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), "%s set to %t for %s\n", args[1], value, args[0]) + return nil + }, + } + c.Flags().StringVar(&reason, "reason", "", "audit note stored on the override") + flags.register(c) + return c +} + +func newUsersFeaturesClearCmd() *cobra.Command { + flags := &adminFlags{} + c := &cobra.Command{ + Use: "clear ", + Short: "Clear an override (revert to the registry default)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, _, err := flags.allowlistClient(cmd) + if err != nil { + return err + } + userID, err := resolveUserID(ctx, client, args[0]) + if err != nil { + return err + } + if err := client.ClearUserFeature(ctx, userID, args[1]); err != nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), "%s override cleared for %s\n", args[1], args[0]) + return nil + }, + } + flags.register(c) + return c +} + +// newUsersConnectionsCmd builds `users connections`. + const defaultGrafanaURL = "https://grafana.futostatus.com" func newUsersViewDashboardCmd() *cobra.Command { @@ -317,3 +446,17 @@ func newAllowlistInviteBatchCmd() *cobra.Command { flags.register(c) return c } + +// resolveUserID turns an --user email into a user id via the admin-api. +func resolveUserID(ctx context.Context, client *adminapi.Client, email string) (string, error) { + users, err := client.ListUsers(ctx, 0) + if err != nil { + return "", err + } + for _, u := range users { + if strings.EqualFold(u.Email, email) { + return u.ID, nil + } + } + return "", fmt.Errorf("no user with email %q", email) +}