mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat(yucca): per-user feature flags (#389)
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
// eslint-disable-next-line unicorn/no-empty-file
|
||||
export * from './features';
|
||||
|
||||
@@ -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:<code>' or 'cluster:<code>'"
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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 { 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<void> {
|
||||
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' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -482,13 +482,20 @@
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "string"
|
||||
},
|
||||
"features": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"sessionId"
|
||||
"sessionId",
|
||||
"features"
|
||||
]
|
||||
},
|
||||
"MetaConfigDto": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,4 +12,7 @@ export class AuthDto {
|
||||
|
||||
@ApiProperty()
|
||||
sessionId!: string;
|
||||
|
||||
@ApiProperty({ type: 'object', additionalProperties: { type: 'boolean' } })
|
||||
features!: Record<string, boolean>;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum CookieName {
|
||||
|
||||
export enum MetadataKey {
|
||||
Auth = 'AUTH',
|
||||
Feature = 'FEATURE',
|
||||
}
|
||||
|
||||
export enum DatabaseLock {
|
||||
|
||||
@@ -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<AuthRequest>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
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 () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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<URL | void> {
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -50,6 +50,7 @@ func NewRootCmd() *cobra.Command {
|
||||
newInfraCmd(),
|
||||
newUsersCmd(),
|
||||
newConfigCmd(),
|
||||
newFeaturesCmd(),
|
||||
newToolsCmd(),
|
||||
)
|
||||
return root
|
||||
|
||||
@@ -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 <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"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user