feat(yucca): per-user feature flags (#389)

This commit is contained in:
Antoine Lecompte
2026-08-03 14:09:22 -04:00
committed by GitHub
parent caf120d8e5
commit 94a5df9667
34 changed files with 1554 additions and 167 deletions
+68
View File
@@ -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('<key>')` stacked on
`@AuthRoute()` (403 when off), and returns the resolved flags on `GET /auth`
as `features: Record<string, boolean>` — 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 <email> # one user's resolved flags + overrides
yuctl users features set <email> connection-restic on --reason "early access"
yuctl users features clear <email> 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.
+29 -87
View File
@@ -68,9 +68,7 @@
"justifyMode": "auto", "justifyMode": "auto",
"orientation": "auto", "orientation": "auto",
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"fields": "", "fields": "",
"values": false "values": false
}, },
@@ -134,9 +132,7 @@
"justifyMode": "auto", "justifyMode": "auto",
"orientation": "auto", "orientation": "auto",
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"fields": "", "fields": "",
"values": false "values": false
}, },
@@ -199,9 +195,7 @@
"justifyMode": "auto", "justifyMode": "auto",
"orientation": "auto", "orientation": "auto",
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"fields": "", "fields": "",
"values": false "values": false
}, },
@@ -257,9 +251,7 @@
"justifyMode": "auto", "justifyMode": "auto",
"orientation": "auto", "orientation": "auto",
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"fields": "", "fields": "",
"values": false "values": false
}, },
@@ -314,9 +306,7 @@
"justifyMode": "auto", "justifyMode": "auto",
"orientation": "auto", "orientation": "auto",
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"fields": "", "fields": "",
"values": false "values": false
}, },
@@ -372,9 +362,7 @@
"justifyMode": "auto", "justifyMode": "auto",
"orientation": "auto", "orientation": "auto",
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"fields": "", "fields": "",
"values": false "values": false
}, },
@@ -461,9 +449,7 @@
"id": 9, "id": 9,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -542,9 +528,7 @@
"id": 10, "id": 10,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -623,9 +607,7 @@
"id": 11, "id": 11,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -720,9 +702,7 @@
"id": 12, "id": 12,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -801,9 +781,7 @@
"id": 18, "id": 18,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -901,9 +879,7 @@
"id": 19, "id": 19,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -996,9 +972,7 @@
"id": 21, "id": 21,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -1088,9 +1062,7 @@
"id": 22, "id": 22,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -1170,9 +1142,7 @@
"id": 23, "id": 23,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -1252,9 +1222,7 @@
"id": 24, "id": 24,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -1355,9 +1323,7 @@
"id": 14, "id": 14,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -1446,9 +1412,7 @@
"id": 15, "id": 15,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -1527,9 +1491,7 @@
"id": 16, "id": 16,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -1608,9 +1570,7 @@
"id": 17, "id": 17,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -1703,9 +1663,7 @@
"id": 26, "id": 26,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -1785,9 +1743,7 @@
"id": 27, "id": 27,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -1866,9 +1822,7 @@
"id": 28, "id": 28,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -1947,9 +1901,7 @@
"id": 29, "id": 29,
"options": { "options": {
"legend": { "legend": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"displayMode": "table", "displayMode": "table",
"placement": "bottom", "placement": "bottom",
"showLegend": true, "showLegend": true,
@@ -2034,9 +1986,7 @@
"justifyMode": "auto", "justifyMode": "auto",
"orientation": "auto", "orientation": "auto",
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"fields": "", "fields": "",
"values": false "values": false
}, },
@@ -2099,9 +2049,7 @@
"justifyMode": "auto", "justifyMode": "auto",
"orientation": "auto", "orientation": "auto",
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"fields": "", "fields": "",
"values": false "values": false
}, },
@@ -2160,9 +2108,7 @@
"justifyMode": "auto", "justifyMode": "auto",
"orientation": "auto", "orientation": "auto",
"reduceOptions": { "reduceOptions": {
"calcs": [ "calcs": ["lastNotNull"],
"lastNotNull"
],
"fields": "", "fields": "",
"values": false "values": false
}, },
@@ -2389,11 +2335,7 @@
"preload": false, "preload": false,
"refresh": "30s", "refresh": "30s",
"schemaVersion": 39, "schemaVersion": 39,
"tags": [ "tags": ["yucca", "michael", "prod"],
"yucca",
"michael",
"prod"
],
"templating": { "templating": {
"list": [ "list": [
{ {
@@ -2445,4 +2387,4 @@
"title": "Yucca: Michael (restic gateway)", "title": "Yucca: Michael (restic gateway)",
"uid": "yucca-michael", "uid": "yucca-michael",
"version": 1 "version": 1
} }
+41
View File
@@ -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<string, FeatureFlagDefinition>;
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<string, boolean> => {
const features: Record<string, boolean> = {};
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;
};
+1 -1
View File
@@ -1 +1 @@
// eslint-disable-next-line unicorn/no-empty-file export * from './features';
+293 -66
View File
@@ -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}": { "/api/session/{id}": {
"delete": { "delete": {
"operationId": "deleteSession", "operationId": "deleteSession",
@@ -701,9 +808,9 @@
] ]
} }
}, },
"/api/settings": { "/api/features": {
"get": { "get": {
"operationId": "listSettings", "operationId": "listFeatures",
"parameters": [], "parameters": [],
"responses": { "responses": {
"200": { "200": {
@@ -711,23 +818,53 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/SettingsListResponseDto" "$ref": "#/components/schemas/FeatureFlagListResponseDto"
} }
} }
} }
} }
}, },
"tags": [ "tags": [
"Settings" "Features"
] ]
} }
}, },
"/api/settings/{scope}": { "/api/features/{flag}/users": {
"put": { "get": {
"operationId": "setSettings", "operationId": "listFeatureUsers",
"parameters": [ "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, "required": true,
"in": "path", "in": "path",
"schema": { "schema": {
@@ -740,7 +877,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/SettingsValueDto" "$ref": "#/components/schemas/FeatureEnableBatchRequestDto"
} }
} }
} }
@@ -751,35 +888,14 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/SettingsEntryResponseDto" "$ref": "#/components/schemas/FeatureEnableBatchResponseDto"
} }
} }
} }
} }
}, },
"tags": [ "tags": [
"Settings" "Features"
]
},
"delete": {
"operationId": "deleteSettings",
"parameters": [
{
"name": "scope",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"tags": [
"Settings"
] ]
} }
} }
@@ -951,6 +1067,69 @@
"items" "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": { "RepositoryOwnerDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1013,14 +1192,6 @@
"worm": { "worm": {
"type": "boolean" "type": "boolean"
}, },
"siteCode": {
"type": "string",
"description": "Stable internal site code"
},
"storageClusterCode": {
"type": "string",
"description": "Stable, globally unique internal storage cluster code"
},
"user": { "user": {
"$ref": "#/components/schemas/RepositoryOwnerDto" "$ref": "#/components/schemas/RepositoryOwnerDto"
}, },
@@ -1032,8 +1203,6 @@
"id", "id",
"name", "name",
"worm", "worm",
"siteCode",
"storageClusterCode",
"user", "user",
"metrics" "metrics"
] ]
@@ -1068,10 +1237,6 @@
}, },
"worm": { "worm": {
"type": "boolean" "type": "boolean"
},
"site": {
"type": "string",
"description": "Internal site code to place the repository in; defaults to the topology default site"
} }
}, },
"required": [ "required": [
@@ -1252,61 +1417,123 @@
"count" "count"
] ]
}, },
"SettingsValueDto": { "FeatureFlagDefDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"restic_pack_size_mib": { "key": {
"type": "number" "type": "string"
}, },
"connections_math": { "default": {
"type": "boolean",
"description": "Registry default when no override exists"
},
"stage": {
"type": "string", "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", "type": "object",
"properties": { "properties": {
"scope": { "flags": {
"type": "string", "type": "array",
"description": "'global', 'site:<code>' or 'cluster:<code>'" "items": {
"$ref": "#/components/schemas/FeatureFlagDefDto"
}
}
},
"required": [
"flags"
]
},
"FeatureUserDto": {
"type": "object",
"properties": {
"userId": {
"type": "string"
},
"email": {
"type": "string"
}, },
"value": { "value": {
"$ref": "#/components/schemas/SettingsValueDto" "type": "boolean"
},
"setBy": {
"type": "string"
},
"reason": {
"type": "object",
"nullable": true
}, },
"updatedAt": { "updatedAt": {
"type": "string" "type": "string"
} }
}, },
"required": [ "required": [
"scope", "userId",
"email",
"value", "value",
"setBy",
"updatedAt" "updatedAt"
] ]
}, },
"SettingsListResponseDto": { "FeatureUsersResponseDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"settings": { "items": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/SettingsEntryDto" "$ref": "#/components/schemas/FeatureUserDto"
} }
} }
}, },
"required": [ "required": [
"settings" "items"
] ]
}, },
"SettingsEntryResponseDto": { "FeatureEnableBatchRequestDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"entry": { "count": {
"$ref": "#/components/schemas/SettingsEntryDto" "type": "number",
"description": "Enable for this many not-yet-overridden users, oldest first"
} }
}, },
"required": [ "required": [
"entry" "count"
]
},
"FeatureEnableBatchResponseDto": {
"type": "object",
"properties": {
"enabled": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FeatureUserDto"
}
}
},
"required": [
"enabled"
] ]
} }
} }
@@ -6,6 +6,7 @@ import { KyselyModule } from 'nestjs-kysely';
import { createPublicKey } from 'node:crypto'; import { createPublicKey } from 'node:crypto';
import { AllowlistController } from './controllers/allowlist.controller'; import { AllowlistController } from './controllers/allowlist.controller';
import { AuthController } from './controllers/auth.controller'; import { AuthController } from './controllers/auth.controller';
import { FeaturesController } from './controllers/features.controller';
import { RepositoryController } from './controllers/repository.controller'; import { RepositoryController } from './controllers/repository.controller';
import { SessionController } from './controllers/session.controller'; import { SessionController } from './controllers/session.controller';
import { SettingsController } from './controllers/settings.controller'; import { SettingsController } from './controllers/settings.controller';
@@ -13,6 +14,7 @@ import { UserController } from './controllers/user.controller';
import { env } from './env'; import { env } from './env';
import { AuthGuard } from './middleware/auth.guard'; import { AuthGuard } from './middleware/auth.guard';
import { DatabaseRepository } from './repositories/database.repository'; import { DatabaseRepository } from './repositories/database.repository';
import { FeatureFlagRepository } from './repositories/featureFlag.repository';
import { OidcRepository } from './repositories/oidc.repository'; import { OidcRepository } from './repositories/oidc.repository';
import { RepositoryRepository } from './repositories/repository.repository'; import { RepositoryRepository } from './repositories/repository.repository';
import { SessionRepository } from './repositories/session.repository'; import { SessionRepository } from './repositories/session.repository';
@@ -24,6 +26,7 @@ import { UserAllowlistRepository } from './repositories/userAllowlist.repository
import { AllowlistService } from './services/allowlist.service'; import { AllowlistService } from './services/allowlist.service';
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
import { DatabaseService } from './services/database.service'; import { DatabaseService } from './services/database.service';
import { FeaturesService } from './services/features.service';
import { RepositoryService } from './services/repository.service'; import { RepositoryService } from './services/repository.service';
import { SessionService } from './services/session.service'; import { SessionService } from './services/session.service';
import { SettingsService } from './services/settings.service'; import { SettingsService } from './services/settings.service';
@@ -51,6 +54,7 @@ export const controllers = [
RepositoryController, RepositoryController,
AllowlistController, AllowlistController,
SettingsController, SettingsController,
FeaturesController,
]; ];
export const providers = [ export const providers = [
@@ -66,6 +70,7 @@ export const providers = [
SettingsRepository, SettingsRepository,
StorageRepository, StorageRepository,
TopologyRepository, TopologyRepository,
FeatureFlagRepository,
AllowlistService, AllowlistService,
AuthService, AuthService,
UserService, UserService,
@@ -73,6 +78,7 @@ export const providers = [
SettingsService, SettingsService,
TopologyService, TopologyService,
RepositoryService, RepositoryService,
FeaturesService,
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_GUARD, useClass: AuthGuard }, { provide: APP_GUARD, useClass: AuthGuard },
]; ];
@@ -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<FeatureFlagListResponseDto> {
return this.features.list();
}
@Get('/:flag/users')
@AuthRoute()
@ApiOkResponse({ type: FeatureUsersResponseDto })
listFeatureUsers(@Param('flag') flag: string): Promise<FeatureUsersResponseDto> {
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<FeatureEnableBatchResponseDto> {
return this.features.enableBatch(auth, flag, dto);
}
}
@@ -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 { 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 { SessionListResponseDto } from 'src/dto/session.dto';
import { import {
UserGetResponseDto, UserGetResponseDto,
@@ -8,7 +10,8 @@ import {
UserUpdateRequestDto, UserUpdateRequestDto,
UserUpdateResponseDto, UserUpdateResponseDto,
} from 'src/dto/user.dto'; } 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 { SessionService } from 'src/services/session.service';
import { UserService } from 'src/services/user.service'; import { UserService } from 'src/services/user.service';
@@ -17,6 +20,7 @@ export class UserController {
constructor( constructor(
private readonly user: UserService, private readonly user: UserService,
private readonly session: SessionService, private readonly session: SessionService,
private readonly features: FeaturesService,
) {} ) {}
@Get() @Get()
@@ -60,4 +64,30 @@ export class UserController {
deleteUserSessions(@Param('userId') userId: string): Promise<void> { deleteUserSessions(@Param('userId') userId: string): Promise<void> {
return this.session.deleteForUser(userId); return this.session.deleteForUser(userId);
} }
@Get('/:id/features')
@AuthRoute()
@ApiOkResponse({ type: UserFeaturesResponseDto })
getUserFeatures(@Param('id') id: string): Promise<UserFeaturesResponseDto> {
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<FeatureOverrideDto> {
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<void> {
return this.features.clear(auth, id, flag);
}
} }
@@ -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<string, boolean>;
@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[];
}
@@ -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<DB>) {}
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<string>`${userId}::uuid`)
.where('flag', '=', flag)
.execute();
}
countsByFlag() {
return this.db
.selectFrom('userFeatureFlagOverride')
.select(['flag'])
.select((eb) => eb.fn.countAll<number>().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();
}
}
@@ -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<FeatureFlagListResponseDto> {
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<UserFeaturesResponseDto> {
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<void> {
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<FeatureUsersResponseDto> {
this.assertKnownFlag(flag);
return { items: await this.featureFlags.listByFlag(flag) };
}
async enableBatch(
auth: AuthDto,
flag: string,
dto: FeatureEnableBatchRequestDto,
): Promise<FeatureEnableBatchResponseDto> {
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 };
}
}
@@ -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<App>;
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' }),
]);
});
});
});
+10 -2
View File
@@ -16,6 +16,7 @@ function getDb() {
export const testUtils = { export const testUtils = {
resetDatabase: async () => { resetDatabase: async () => {
const db = getDb(); const db = getDb();
await db.deleteFrom('userFeatureFlagOverride').execute();
await db.deleteFrom('repositories').execute(); await db.deleteFrom('repositories').execute();
await db.deleteFrom('sessions').execute(); await db.deleteFrom('sessions').execute();
await db.deleteFrom('users').execute(); await db.deleteFrom('users').execute();
@@ -55,10 +56,17 @@ export const testUtils = {
email, email,
sub, sub,
disabled = false, 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() return getDb()
.insertInto('users') .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() .returningAll()
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
}, },
@@ -86,7 +86,14 @@ describe('UserController (e2e)', () => {
.expect(200); .expect(200);
expect(body).toEqual({ 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),
},
}); });
}); });
}); });
+8 -1
View File
@@ -482,13 +482,20 @@
}, },
"sessionId": { "sessionId": {
"type": "string" "type": "string"
},
"features": {
"type": "object",
"additionalProperties": {
"type": "boolean"
}
} }
}, },
"required": [ "required": [
"id", "id",
"name", "name",
"email", "email",
"sessionId" "sessionId",
"features"
] ]
}, },
"MetaConfigDto": { "MetaConfigDto": {
@@ -19,6 +19,9 @@ export type AuthDto = {
name: string; name: string;
email: string; email: string;
sessionId: string; sessionId: string;
features: {
[key: string]: boolean;
};
}; };
export type MetaConfigDto = { export type MetaConfigDto = {
restic_pack_size_mib: number; restic_pack_size_mib: number;
+3
View File
@@ -12,4 +12,7 @@ export class AuthDto {
@ApiProperty() @ApiProperty()
sessionId!: string; sessionId!: string;
@ApiProperty({ type: 'object', additionalProperties: { type: 'boolean' } })
features!: Record<string, boolean>;
} }
+1
View File
@@ -7,6 +7,7 @@ export enum CookieName {
export enum MetadataKey { export enum MetadataKey {
Auth = 'AUTH', Auth = 'AUTH',
Feature = 'FEATURE',
} }
export enum DatabaseLock { export enum DatabaseLock {
@@ -1,6 +1,8 @@
import { FeatureFlagKey } from '@common/server';
import { import {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
ForbiddenException,
Injectable, Injectable,
Scope, Scope,
SetMetadata, SetMetadata,
@@ -17,6 +19,10 @@ export const AuthRoute = (options = {}): MethodDecorator => {
return applyDecorators(SetMetadata(MetadataKey.Auth, options)); return applyDecorators(SetMetadata(MetadataKey.Auth, options));
}; };
export const RequireFeature = (flag: FeatureFlagKey): MethodDecorator => {
return applyDecorators(SetMetadata(MetadataKey.Feature, flag));
};
export interface AuthRequest extends Request { export interface AuthRequest extends Request {
auth: AuthDto; auth: AuthDto;
} }
@@ -41,6 +47,12 @@ export class AuthGuard implements CanActivate {
const request = context.switchToHttp().getRequest<AuthRequest>(); const request = context.switchToHttp().getRequest<AuthRequest>();
request.auth = await this.service.authenticate(request.headers); request.auth = await this.service.authenticate(request.headers);
const feature = this.reflector.getAllAndOverride<FeatureFlagKey | undefined>(MetadataKey.Feature, targets);
if (feature && !request.auth.features[feature]) {
throw new ForbiddenException(`Feature '${feature}' is not enabled for this account`);
}
return true; return true;
} }
} }
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql, Updateable } from 'kysely'; import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
@@ -22,7 +23,21 @@ export class UserRepository {
.where('accessToken', '=', accessToken) .where('accessToken', '=', accessToken)
.innerJoin('users', 'users.id', 'sessions.userId') .innerJoin('users', 'users.id', 'sessions.userId')
.where('users.disabled', '=', false) .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(); .executeTakeFirst();
} }
+3
View File
@@ -8,6 +8,7 @@ import { SessionTable } from './tables/session.table';
import { SettingsTable } from './tables/settings.table'; import { SettingsTable } from './tables/settings.table';
import { UserTable } from './tables/user.table'; import { UserTable } from './tables/user.table';
import { UserAllowlistTable } from './tables/userAllowlist.table'; import { UserAllowlistTable } from './tables/userAllowlist.table';
import { UserFeatureFlagOverrideTable } from './tables/userFeatureFlagOverride.table';
@Database({ name: 'yucca' }) @Database({ name: 'yucca' })
export class ImmichDatabase { export class ImmichDatabase {
@@ -21,6 +22,7 @@ export class ImmichDatabase {
RepositoryMeterHistoryTable, RepositoryMeterHistoryTable,
UserAllowlistTable, UserAllowlistTable,
SettingsTable, SettingsTable,
UserFeatureFlagOverrideTable,
]; ];
functions = []; functions = [];
@@ -38,4 +40,5 @@ export interface DB {
repositoryMeterHistory: RepositoryMeterHistoryTable; repositoryMeterHistory: RepositoryMeterHistoryTable;
userAllowlist: UserAllowlistTable; userAllowlist: UserAllowlistTable;
settings: SettingsTable; settings: SettingsTable;
userFeatureFlagOverride: UserFeatureFlagOverrideTable;
} }
@@ -0,0 +1,25 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await sql`DROP TABLE "userFeatureFlagOverride";`.execute(db);
await sql`ALTER TABLE "users" DROP COLUMN "createdAt";`.execute(db);
}
@@ -16,4 +16,7 @@ export class UserTable {
@Column({ type: 'boolean', default: () => 'false' }) @Column({ type: 'boolean', default: () => 'false' })
disabled!: Generated<boolean>; disabled!: Generated<boolean>;
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
createdAt!: Generated<Date>;
} }
@@ -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<string>;
@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<Date>;
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
updatedAt!: Generated<Date>;
}
@@ -62,14 +62,38 @@ describe(AuthService.name, () => {
}); });
it('should return user if one is found', async () => { it('should return user if one is found', async () => {
mocks.user.getByAccessToken.mockResolvedValue(mockUser); mocks.user.getByAccessToken.mockResolvedValue({ ...mockUser, featureOverrides: [] });
await expect( await expect(
sut.authenticate({ sut.authenticate({
cookie: 'yucca-access-token=my-token', cookie: 'yucca-access-token=my-token',
}), }),
).resolves.toBe(mockUser); ).resolves.toEqual({ ...mockUser, features: { 'connection-restic': false } });
expect(mocks.wideContext.addContext).toHaveBeenCalledWith('customerId', mockUser.id); 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', () => { describe('logout', () => {
@@ -1,3 +1,4 @@
import { resolveFeatures } from '@common/server';
import { LoggerRepository, WideContextRepository } from '@common/server/otel'; import { LoggerRepository, WideContextRepository } from '@common/server/otel';
import { Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'; import { Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common';
import { parse } from 'cookie'; import { parse } from 'cookie';
@@ -36,14 +37,15 @@ export class AuthService {
throw new UnauthorizedException(`Missing ${CookieName.AccessToken} cookie`); throw new UnauthorizedException(`Missing ${CookieName.AccessToken} cookie`);
} }
const user = await this.user.getByAccessToken(accessToken); const row = await this.user.getByAccessToken(accessToken);
if (!user) { if (!row) {
throw new UnauthorizedException(`Invalid access token`); 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<URL | void> { async logout(auth: AuthDto): Promise<URL | void> {
@@ -5,3 +5,9 @@ export class EmailNotAllowedException extends ForbiddenException {
super('Email is not allowed during the beta'); 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`);
}
}
@@ -54,9 +54,21 @@ describe('AuthController (e2e)', () => {
name: user.name, name: user.name,
email: user.email, email: user.email,
sessionId: session.id, 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 () => { it('rejects a disabled user with an existing session', async () => {
await testUtils.disableUser(user.id); await testUtils.disableUser(user.id);
+10
View File
@@ -19,6 +19,7 @@ function getDb() {
export const testUtils = { export const testUtils = {
resetDatabase: async () => { resetDatabase: async () => {
const db = getDb(); const db = getDb();
await db.deleteFrom('userFeatureFlagOverride').execute();
await db.deleteFrom('repositories').execute(); await db.deleteFrom('repositories').execute();
await db.deleteFrom('sessions').execute(); await db.deleteFrom('sessions').execute();
await db.deleteFrom('users').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) => { getUserBySub: (sub: string) => {
const db = getDb(); const db = getDb();
const userRepository = new UserRepository(db); const userRepository = new UserRepository(db);
@@ -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
}
+75
View File
@@ -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
}
+118
View File
@@ -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 <flag>",
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 <flag> <count>",
Short: "Enable a flag for the oldest <count> 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
}
+1
View File
@@ -50,6 +50,7 @@ func NewRootCmd() *cobra.Command {
newInfraCmd(), newInfraCmd(),
newUsersCmd(), newUsersCmd(),
newConfigCmd(), newConfigCmd(),
newFeaturesCmd(),
newToolsCmd(), newToolsCmd(),
) )
return root return root
+143
View File
@@ -1,6 +1,7 @@
package cli package cli
import ( import (
"context"
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
@@ -22,9 +23,137 @@ func newUsersCmd() *cobra.Command {
cmd.AddCommand(newUsersListCmd()) cmd.AddCommand(newUsersListCmd())
cmd.AddCommand(newUsersAllowlistCmd()) cmd.AddCommand(newUsersAllowlistCmd())
cmd.AddCommand(newUsersViewDashboardCmd()) cmd.AddCommand(newUsersViewDashboardCmd())
cmd.AddCommand(newUsersFeaturesCmd())
return cmd 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 <email>",
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 <email> <flag> 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 <email> <flag>",
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" const defaultGrafanaURL = "https://grafana.futostatus.com"
func newUsersViewDashboardCmd() *cobra.Command { func newUsersViewDashboardCmd() *cobra.Command {
@@ -317,3 +446,17 @@ func newAllowlistInviteBatchCmd() *cobra.Command {
flags.register(c) flags.register(c)
return 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)
}