diff --git a/BACKLOG.md b/BACKLOG.md index 64054e7..0cae8a2 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -569,9 +569,10 @@ portal is not done — the page shows; the console and ctl change. client tests against a fake admin API and the OpenAPI document; acceptance tests with Terraform and OpenTofu against the fake; one apply, empty plan, in-place change and destroy against a real `llmgw - server` run by hand. Left: organizations, teams, projects, people and - keys are not in the admin API (they are `llmgw org`/`users`/`keys` - against the store), so not in the provider; a model's definition beyond + server` run by hand. Organizations, teams, projects, keys and + memberships are in the admin API since 2026-10-08 (`/api/v1/orgs`, + `teams`, `projects`, `keys`, `users/{id}/memberships`); accounts are + still made by sign-up, invitation or OIDC/SCIM. Left: a model's definition beyond scaling, class, owner and adapters is changed by replacing it (the API has no edit, and `resources` is not used yet); `canary`, `slo`, `scaling.schedule` and `prewarm`, `guard` role and `origin` are not in diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b38ca6..994aa9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,13 @@ APIs change between minors without a deprecation period. not the organization, so two teams of one organization no longer share cached passages. +- **Organizations, teams, projects, API keys and memberships in the admin + API**: `/api/v1/orgs` (the tree), `POST /api/v1/teams` and `projects`, + `PATCH` to rename or move, `DELETE` for what is empty; `/api/v1/keys` to + issue (the key is in the answer, once), list and revoke; `/api/v1/users` + and `/api/v1/users/{id}/memberships`. They were only `llmgw org`, `keys` + and `users` on a control plane before. + ### Changed - **The store's PostgreSQL is sized to its machine's memory.** Before, it ran diff --git a/docs/admin-api.md b/docs/admin-api.md index 964969e..a666598 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -8,8 +8,9 @@ description is. anything they do can be done with `curl`, a generated client or the [Terraform provider](../terraform-provider-llmgw/README.md). It adds and removes models, scales them, cordons and upgrades nodes, sets capacity -quotas, attaches guardrail policies, and reads the fleet, usage and the audit -log. +quotas, attaches guardrail policies, adds organizations, teams and projects, +issues and revokes API keys, puts people in the tree, and reads the fleet, +usage and the audit log. ## Reaching it @@ -99,6 +100,14 @@ curl -s "${H[@]}" -X POST $API/models/qwen-small/scale -d '{"min": 0, "max": 2, # A team's capacity quota. curl -s "${H[@]}" -X PUT $API/quotas -d '{"scope": "team:Acme/ml", "limit": {"gpus": 8}}' +# An organization, a team in it and a project in that; then a key for the +# project. The answer holds the key once: only its hash is kept. +curl -s "${H[@]}" -X POST $API/orgs -d '{"name": "Acme"}' +curl -s "${H[@]}" -X POST $API/teams -d '{"name": "ml", "in": "Acme"}' +curl -s "${H[@]}" -X POST $API/projects -d '{"name": "search", "in": "Acme/ml"}' +curl -s "${H[@]}" -X POST $API/keys -d '{"name": "ci", "project": "Acme/ml/search", "expires_in": "720h"}' +curl -s "${H[@]}" -X POST $API/users/ayse@example.com/memberships -d '{"target": "team:Acme/ml"}' + # Remove the model; a DELETE carries the content type too. curl -s "${H[@]}" -X DELETE $API/models/qwen-small ``` @@ -149,11 +158,11 @@ go generate ./internal/docgen ## What it does not cover -- **Organizations, teams, projects, people and API keys.** These live in the - store and are managed with `llmgw org`, `llmgw users` and `llmgw keys` on the - control plane, or in the web console's `/admin` pages on the client port - behind a session. The admin API reads the tree only where a quota or a - policy names a part of it. +- **Making people's accounts.** People sign up, are invited, or come from + the identity provider (OIDC, SCIM); the API lists them and puts them in + the tree, and `llmgw users` approves, disables and deletes them. Key + lifetime limits and spending limits are `llmgw keys ttl` and `llmgw keys + limits`. - **The client-facing API** (`/v1/chat/completions` and the rest), which is OpenAI's and Anthropic's, on `listen`, with API keys. - **The rest of the admin listener**: `/metrics`, `/health`, the dashboard diff --git a/docs/admin-api.openapi.json b/docs/admin-api.openapi.json index a0f4077..c7fd4cd 100644 --- a/docs/admin-api.openapi.json +++ b/docs/admin-api.openapi.json @@ -56,6 +56,9 @@ { "name": "kb" }, + { + "name": "keys" + }, { "name": "metrics" }, @@ -74,9 +77,15 @@ { "name": "openapi" }, + { + "name": "orgs" + }, { "name": "plan" }, + { + "name": "projects" + }, { "name": "quotas" }, @@ -95,6 +104,9 @@ { "name": "summary" }, + { + "name": "teams" + }, { "name": "train" }, @@ -104,6 +116,9 @@ { "name": "usage" }, + { + "name": "users" + }, { "name": "weights" } @@ -1502,6 +1517,166 @@ } } }, + "/api/v1/keys": { + "get": { + "operationId": "getKeys", + "summary": "API keys, with their project and owner; never the keys themselves", + "tags": [ + "keys" + ], + "parameters": [ + { + "name": "project", + "in": "query", + "description": "Only this project's keys: ORG/TEAM/PROJECT or its ID.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/KeyView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "postKeys", + "summary": "Issue an API key", + "description": "The answer holds the key, once: only its hash is kept. A lifetime longer than a scope above it allows is refused.", + "tags": [ + "keys" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeyCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeyCreated" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/keys/{id}": { + "get": { + "operationId": "getKeysId", + "summary": "One API key", + "tags": [ + "keys" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The key's ID, as listed (not the key itself).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeyView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteKeysId", + "summary": "Revoke an API key", + "description": "A soft delete: usage refers to it. A gateway that has it cached accepts it until auth.cache_ttl.", + "tags": [ + "keys" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The key's ID, as listed (not the key itself).", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/api/v1/metrics/range": { "post": { "operationId": "postMetricsRange", @@ -3131,6 +3306,167 @@ } } }, + "/api/v1/orgs": { + "get": { + "operationId": "getOrgs", + "summary": "The tenancy tree: every organization, its teams and their projects", + "tags": [ + "orgs" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrgView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "postOrgs", + "summary": "Add an organization", + "tags": [ + "orgs" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/orgs/{id}": { + "patch": { + "operationId": "patchOrgsId", + "summary": "Rename an organization", + "tags": [ + "orgs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The organization's ID or name.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyChangeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteOrgsId", + "summary": "Remove an organization that has no teams", + "tags": [ + "orgs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The organization's ID or name.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/api/v1/plan": { "get": { "operationId": "getPlan", @@ -3185,6 +3521,140 @@ } } }, + "/api/v1/projects": { + "post": { + "operationId": "postProjects", + "summary": "Add a project to a team", + "tags": [ + "projects" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/projects/{id}": { + "patch": { + "operationId": "patchProjectsId", + "summary": "Rename a project, or move it to another team", + "tags": [ + "projects" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The project's ID.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyChangeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteProjectsId", + "summary": "Remove a project with no people, keys or collections", + "description": "A project that ever had a key keeps it, revoked, because usage refers to it; rename such a project instead.", + "tags": [ + "projects" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The project's ID.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/api/v1/quotas": { "get": { "operationId": "getQuotas", @@ -3692,6 +4162,140 @@ } } }, + "/api/v1/teams": { + "post": { + "operationId": "postTeams", + "summary": "Add a team to an organization", + "tags": [ + "teams" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/teams/{id}": { + "patch": { + "operationId": "patchTeamsId", + "summary": "Rename a team, or move it to another organization", + "description": "Everything under it moves too, and from then on inherits the new organization's limits.", + "tags": [ + "teams" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The team's ID.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyChangeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TenancyItem" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteTeamsId", + "summary": "Remove a team that has no projects", + "tags": [ + "teams" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The team's ID.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {} + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/api/v1/train/jobs": { "post": { "operationId": "postTrainJobs", @@ -4182,6 +4786,186 @@ } } }, + "/api/v1/users": { + "get": { + "operationId": "getUsers", + "summary": "People, with where each is a member", + "tags": [ + "users" + ], + "parameters": [ + { + "name": "state", + "in": "query", + "description": "Only people in this state: pending, active or disabled.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/users/{id}": { + "get": { + "operationId": "getUsersId", + "summary": "One person, with where they are a member", + "tags": [ + "users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The person's ID or email.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/users/{id}/memberships": { + "post": { + "operationId": "postUsersIdMemberships", + "summary": "Put a person in an organization, team or project", + "description": "Already there, only the role changes.", + "tags": [ + "users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The person's ID or email.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "deleteUsersIdMemberships", + "summary": "Take a person out of an organization, team or project", + "description": "Their keys in projects they no longer reach are revoked.", + "tags": [ + "users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The person's ID or email.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/api/v1/weights/collect": { "post": { "operationId": "postWeightsCollect", @@ -5615,6 +6399,144 @@ } } }, + "KeyCreateRequest": { + "type": "object", + "description": "KeyCreateRequest issues a key: a project's, a person's in a project they are a member of, or a person's own with no project.", + "properties": { + "name": { + "type": "string" + }, + "project": { + "type": "string", + "description": "Project is ORG/TEAM/PROJECT or its ID." + }, + "user": { + "type": "string", + "description": "User is the person it belongs to, by ID or email." + }, + "band": { + "type": "string" + }, + "routing_profile": { + "type": "string" + }, + "expires_in": { + "type": "string", + "description": "ExpiresIn is its lifetime, as 720h; none never expires, unless a scope above it limits how long keys live." + } + } + }, + "KeyCreated": { + "type": "object", + "description": "KeyCreated is a new key, with the key itself: this answer is the only time it is given.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "prefix": { + "type": "string", + "description": "Prefix is the key's first characters, to tell it apart." + }, + "project": { + "type": "string" + }, + "project_path": { + "type": "string", + "description": "ProjectPath is ORG/TEAM/PROJECT, or empty for a personal key." + }, + "user": { + "type": "string" + }, + "user_email": { + "type": "string" + }, + "band": { + "type": "string" + }, + "routing_profile": { + "type": "string" + }, + "state": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "revoked_at": { + "type": "string", + "format": "date-time" + }, + "last_used_at": { + "type": "string", + "format": "date-time" + }, + "secret": { + "type": "string" + } + } + }, + "KeyView": { + "type": "object", + "description": "KeyView is a key as listed: never the key itself, which is not kept.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "prefix": { + "type": "string", + "description": "Prefix is the key's first characters, to tell it apart." + }, + "project": { + "type": "string" + }, + "project_path": { + "type": "string", + "description": "ProjectPath is ORG/TEAM/PROJECT, or empty for a personal key." + }, + "user": { + "type": "string" + }, + "user_email": { + "type": "string" + }, + "band": { + "type": "string" + }, + "routing_profile": { + "type": "string" + }, + "state": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "revoked_at": { + "type": "string", + "format": "date-time" + }, + "last_used_at": { + "type": "string", + "format": "date-time" + } + } + }, "Live": { "type": "object", "description": "Live is one of the cluster's adapters as its node last reported it.", @@ -5730,6 +6652,42 @@ } } }, + "MembershipRequest": { + "type": "object", + "description": "MembershipRequest puts a person in an organization, team or project, or with DELETE takes them out.", + "properties": { + "target": { + "type": "string", + "description": "Target is org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT, or the ID after the colon." + }, + "role": { + "type": "string", + "description": "Role is member or manager; member when absent. Ignored by DELETE." + } + } + }, + "MembershipView": { + "type": "object", + "description": "MembershipView is one place a person belongs.", + "properties": { + "target": { + "type": "string" + }, + "path": { + "type": "string" + }, + "role": { + "type": "string" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "by": { + "type": "string" + } + } + }, "MetricsQuery": { "type": "object", "description": "MetricsQuery is one line set of a chart: a PromQL expression, and the label naming each line.", @@ -6245,6 +7203,27 @@ } } }, + "OrgView": { + "type": "object", + "description": "OrgView is an organization with what is in it.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "members": { + "type": "integer" + }, + "teams": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TeamView" + } + } + } + }, "OriginRequest": { "type": "object", "description": "OriginRequest names the repository a model's weights came from.", @@ -6326,6 +7305,34 @@ } } }, + "ProjectView": { + "type": "object", + "description": "ProjectView is a project, with how many people and keys are in it.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "team": { + "type": "string" + }, + "path": { + "type": "string", + "description": "Path is ORG/TEAM/PROJECT by name." + }, + "users": { + "type": "integer" + }, + "keys": { + "type": "integer" + }, + "active_keys": { + "type": "integer" + } + } + }, "QuotaRequest": { "type": "object", "description": "QuotaRequest sets a scope's quota. Scope may be written with an ID or a name: org:Acme, team:Acme/ml, project:Acme/ml/training.", @@ -7053,6 +8060,72 @@ } } }, + "TeamView": { + "type": "object", + "description": "TeamView is a team with its projects.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "members": { + "type": "integer" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectView" + } + } + } + }, + "TenancyChangeRequest": { + "type": "object", + "description": "TenancyChangeRequest renames an organization, team or project, or moves a team to another organization or a project to another team.", + "properties": { + "name": { + "type": "string" + }, + "in": { + "type": "string", + "description": "In is where it moves to, by name or ID; not for an organization." + } + } + }, + "TenancyCreateRequest": { + "type": "object", + "description": "TenancyCreateRequest adds an organization, a team or a project.", + "properties": { + "name": { + "type": "string" + }, + "in": { + "type": "string", + "description": "In is the organization a team goes in, or the team a project goes in (ORG/TEAM), by name or ID; nothing for an organization." + } + } + }, + "TenancyItem": { + "type": "object", + "description": "TenancyItem is an organization, team or project added or changed.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "in": { + "type": "string", + "description": "In is the organization a team is in, or the team a project is in, by ID." + } + } + }, "TopologyView": { "type": "object", "description": "TopologyView is one fabric's map, with the cluster's own adapters on it live: their state, rate and traffic from the last heartbeat.", @@ -7284,6 +8357,42 @@ } } }, + "UserView": { + "type": "object", + "description": "UserView is a person, with where they are a member.", + "properties": { + "id": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "state": { + "type": "string" + }, + "role": { + "type": "string", + "description": "Role is the person's part in running the gateway, if any: viewer, operator, billing or admin." + }, + "memberships": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MembershipView" + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "last_login_at": { + "type": "string", + "format": "date-time" + } + } + }, "bench.Capacity": { "type": "object", "description": "Capacity is what one replica was measured to serve, kept so the measurement outlives the terminal it was printed in.\n\nThe conclusion of a run, not the run. The ramp itself — every concurrency level, its latencies, its errors — is worth reading once, while deciding whether the run was sound; it is not worth carrying around afterwards, and storing it would grow without bound as a model is re-benchmarked. What a later reader needs is the number and enough provenance to judge whether it is still true.", diff --git a/internal/cli/server.go b/internal/cli/server.go index b21ff25..04ad803 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -457,6 +457,7 @@ func newServerCmd() *cobra.Command { api.SetUsage(st) // Capacity quotas, set and read over this API. api.SetQuotas(st) + api.SetTenancy(st) } api.Register(adminMux) diff --git a/internal/control/adminapi/api.go b/internal/control/adminapi/api.go index 107b1d0..6e770ed 100644 --- a/internal/control/adminapi/api.go +++ b/internal/control/adminapi/api.go @@ -71,6 +71,8 @@ type API struct { guard *GuardAdmin // kb is knowledge bases; nil without a store. kb *KBAdmin + // tenancy is organizations, keys and people; nil without a store. + tenancy TenancyStore // train is adapter training; nil without a store and agents. train *TrainAdmin settings *Settings @@ -119,6 +121,7 @@ func (a *API) Register(mux Mux) { mux.HandleFunc("POST /api/v1/models/{name}/resources", a.setResources) a.registerQuotas(mux) a.registerKB(mux) + a.registerTenancy(mux) a.registerTrain(mux) a.registerGuard(mux) a.registerSettings(mux) diff --git a/internal/control/adminapi/routes.go b/internal/control/adminapi/routes.go index ca60eed..591ec9c 100644 --- a/internal/control/adminapi/routes.go +++ b/internal/control/adminapi/routes.go @@ -67,6 +67,11 @@ var PathParams = map[string]string{ "federation/{peer}": "The federation peer's name, as `llmgw ctl federation list` lists it.", "holds/{domain}": "The domain as level:name, rack:r12-07, as `llmgw ctl facility` lists it.", "collections/{id}": "The collection's ID, its name when that is unique, or ORG/NAME.", + "orgs/{id}": "The organization's ID or name.", + "teams/{id}": "The team's ID.", + "projects/{id}": "The project's ID.", + "keys/{id}": "The key's ID, as listed (not the key itself).", + "users/{id}": "The person's ID or email.", "artifacts/{kind}": "What the job made: peft, a gzipped tar of the PEFT adapter, or gguf, the adapter for llama.cpp.", } @@ -297,6 +302,50 @@ var Routes = []Route{ Request: KBSearchRequest{}, Response: []KBHitView{}}, // LoRA adapters trained on the nodes. + // Organizations, teams, projects, keys and people. + {Method: "GET", Path: "/api/v1/orgs", Summary: "The tenancy tree: every organization, its teams and their projects", + Response: []OrgView{}}, + {Method: "POST", Path: "/api/v1/orgs", Summary: "Add an organization", + Request: TenancyCreateRequest{}, Response: TenancyItem{}, Status: http.StatusCreated}, + {Method: "PATCH", Path: "/api/v1/orgs/{id}", Summary: "Rename an organization", + Request: TenancyChangeRequest{}, Response: TenancyItem{}}, + {Method: "DELETE", Path: "/api/v1/orgs/{id}", Summary: "Remove an organization that has no teams", + Response: map[string]any{}}, + {Method: "POST", Path: "/api/v1/teams", Summary: "Add a team to an organization", + Request: TenancyCreateRequest{}, Response: TenancyItem{}, Status: http.StatusCreated}, + {Method: "PATCH", Path: "/api/v1/teams/{id}", Summary: "Rename a team, or move it to another organization", + Description: "Everything under it moves too, and from then on inherits the new organization's limits.", + Request: TenancyChangeRequest{}, Response: TenancyItem{}}, + {Method: "DELETE", Path: "/api/v1/teams/{id}", Summary: "Remove a team that has no projects", + Response: map[string]any{}}, + {Method: "POST", Path: "/api/v1/projects", Summary: "Add a project to a team", + Request: TenancyCreateRequest{}, Response: TenancyItem{}, Status: http.StatusCreated}, + {Method: "PATCH", Path: "/api/v1/projects/{id}", Summary: "Rename a project, or move it to another team", + Request: TenancyChangeRequest{}, Response: TenancyItem{}}, + {Method: "DELETE", Path: "/api/v1/projects/{id}", Summary: "Remove a project with no people, keys or collections", + Description: "A project that ever had a key keeps it, revoked, because usage refers to it; rename such a project instead.", + Response: map[string]any{}}, + {Method: "GET", Path: "/api/v1/keys", Summary: "API keys, with their project and owner; never the keys themselves", + Query: []Param{{Name: "project", Description: "Only this project's keys: ORG/TEAM/PROJECT or its ID."}}, + Response: []KeyView{}}, + {Method: "POST", Path: "/api/v1/keys", Summary: "Issue an API key", + Description: "The answer holds the key, once: only its hash is kept. A lifetime longer than a scope above it allows is refused.", + Request: KeyCreateRequest{}, Response: KeyCreated{}, Status: http.StatusCreated}, + {Method: "GET", Path: "/api/v1/keys/{id}", Summary: "One API key", Response: KeyView{}}, + {Method: "DELETE", Path: "/api/v1/keys/{id}", Summary: "Revoke an API key", + Description: "A soft delete: usage refers to it. A gateway that has it cached accepts it until auth.cache_ttl.", + Response: map[string]any{}}, + {Method: "GET", Path: "/api/v1/users", Summary: "People, with where each is a member", + Query: []Param{{Name: "state", Description: "Only people in this state: pending, active or disabled."}}, + Response: []UserView{}}, + {Method: "GET", Path: "/api/v1/users/{id}", Summary: "One person, with where they are a member", Response: UserView{}}, + {Method: "POST", Path: "/api/v1/users/{id}/memberships", Summary: "Put a person in an organization, team or project", + Description: "Already there, only the role changes.", + Request: MembershipRequest{}, Response: UserView{}}, + {Method: "DELETE", Path: "/api/v1/users/{id}/memberships", Summary: "Take a person out of an organization, team or project", + Description: "Their keys in projects they no longer reach are revoked.", + Request: MembershipRequest{}, Response: UserView{}}, + {Method: "POST", Path: "/api/v1/train/jobs", Summary: "Queue a LoRA adapter's training on a node", Description: "The node trains when its agent is there, nothing else trains on it, and a card has gpu_memory_mib free; " + "the pairs are NDJSON of {\"q\", \"a\"}. An adapter learns how answers sound far better than what they say.", diff --git a/internal/control/adminapi/tenancy.go b/internal/control/adminapi/tenancy.go new file mode 100644 index 0000000..9e7977b --- /dev/null +++ b/internal/control/adminapi/tenancy.go @@ -0,0 +1,655 @@ +package adminapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// The tenancy tree, keys and people's memberships, administered over the +// API as `llmgw org`, `keys` and `users` do against the store: so that +// they can be run from elsewhere, and kept in Terraform. + +// TenancyStore is the part of the store the tenancy endpoints need. +type TenancyStore interface { + Tree(ctx context.Context) ([]store.TreeOrg, error) + CreateOrg(ctx context.Context, name string) (string, error) + CreateTeam(ctx context.Context, orgID, name string) (string, error) + CreateProject(ctx context.Context, teamID, name string) (string, error) + RenameOrg(ctx context.Context, id, name string) error + RenameTeam(ctx context.Context, id, name string) error + RenameProject(ctx context.Context, id, name string) error + MoveTeam(ctx context.Context, teamID, orgID string) error + MoveProject(ctx context.Context, projectID, teamID string) error + DeleteOrg(ctx context.Context, id string) error + DeleteTeam(ctx context.Context, id string) error + DeleteProject(ctx context.Context, id string) error + + AllKeys(ctx context.Context) ([]store.KeyRow, error) + KeyByID(ctx context.Context, id string) (store.Key, error) + CreateKey(ctx context.Context, k store.Key) error + CreateUserKey(ctx context.Context, userID string, k store.Key) error + RevokeKey(ctx context.Context, id string, at time.Time) error + + Users(ctx context.Context, state store.UserState) ([]store.User, error) + UserByID(ctx context.Context, id string) (store.User, error) + UserByEmail(ctx context.Context, email string) (store.User, error) + MembershipsOf(ctx context.Context, userID string) ([]store.Membership, error) + AddMembership(ctx context.Context, userID string, scope store.Scope, scopeID, role, by string, at time.Time) error + RemoveMembership(ctx context.Context, userID string, scope store.Scope, scopeID string, at time.Time) (int, error) +} + +// SetTenancy enables the tenancy endpoints. The quota store must be set +// too: names are resolved through it. +func (a *API) SetTenancy(t TenancyStore) { a.tenancy = t } + +// OrgView is an organization with what is in it. +type OrgView struct { + ID string `json:"id"` + Name string `json:"name"` + Members int `json:"members"` + Teams []TeamView `json:"teams"` +} + +// TeamView is a team with its projects. +type TeamView struct { + ID string `json:"id"` + Name string `json:"name"` + Org string `json:"org"` + Members int `json:"members"` + Projects []ProjectView `json:"projects"` +} + +// ProjectView is a project, with how many people and keys are in it. +type ProjectView struct { + ID string `json:"id"` + Name string `json:"name"` + Team string `json:"team"` + // Path is ORG/TEAM/PROJECT by name. + Path string `json:"path"` + Users int `json:"users"` + Keys int `json:"keys"` + ActiveKeys int `json:"active_keys"` +} + +// TenancyCreateRequest adds an organization, a team or a project. +type TenancyCreateRequest struct { + Name string `json:"name"` + // In is the organization a team goes in, or the team a project goes in + // (ORG/TEAM), by name or ID; nothing for an organization. + In string `json:"in,omitempty"` +} + +// TenancyChangeRequest renames an organization, team or project, or moves +// a team to another organization or a project to another team. +type TenancyChangeRequest struct { + Name string `json:"name,omitempty"` + // In is where it moves to, by name or ID; not for an organization. + In string `json:"in,omitempty"` +} + +// TenancyItem is an organization, team or project added or changed. +type TenancyItem struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + // In is the organization a team is in, or the team a project is in, by + // ID. + In string `json:"in,omitempty"` +} + +// KeyView is a key as listed: never the key itself, which is not kept. +type KeyView struct { + ID string `json:"id"` + Name string `json:"name"` + // Prefix is the key's first characters, to tell it apart. + Prefix string `json:"prefix"` + Project string `json:"project,omitempty"` + // ProjectPath is ORG/TEAM/PROJECT, or empty for a personal key. + ProjectPath string `json:"project_path,omitempty"` + User string `json:"user,omitempty"` + UserEmail string `json:"user_email,omitempty"` + Band string `json:"band,omitempty"` + RoutingProfile string `json:"routing_profile,omitempty"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at,omitzero"` + RevokedAt time.Time `json:"revoked_at,omitzero"` + LastUsedAt time.Time `json:"last_used_at,omitzero"` +} + +// KeyCreateRequest issues a key: a project's, a person's in a project they +// are a member of, or a person's own with no project. +type KeyCreateRequest struct { + Name string `json:"name"` + // Project is ORG/TEAM/PROJECT or its ID. + Project string `json:"project,omitempty"` + // User is the person it belongs to, by ID or email. + User string `json:"user,omitempty"` + Band string `json:"band,omitempty"` + RoutingProfile string `json:"routing_profile,omitempty"` + // ExpiresIn is its lifetime, as 720h; none never expires, unless a + // scope above it limits how long keys live. + ExpiresIn string `json:"expires_in,omitempty"` +} + +// KeyCreated is a new key, with the key itself: this answer is the only +// time it is given. +type KeyCreated struct { + KeyView + Secret string `json:"secret"` +} + +// UserView is a person, with where they are a member. +type UserView struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name,omitempty"` + State string `json:"state"` + // Role is the person's part in running the gateway, if any: viewer, + // operator, billing or admin. + Role string `json:"role,omitempty"` + Memberships []MembershipView `json:"memberships"` + CreatedAt time.Time `json:"created_at"` + LastLoginAt time.Time `json:"last_login_at,omitzero"` +} + +// MembershipView is one place a person belongs. +type MembershipView struct { + Target string `json:"target"` + Path string `json:"path,omitempty"` + Role string `json:"role"` + At time.Time `json:"at"` + By string `json:"by,omitempty"` +} + +// MembershipRequest puts a person in an organization, team or project, or +// with DELETE takes them out. +type MembershipRequest struct { + // Target is org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT, or the + // ID after the colon. + Target string `json:"target"` + // Role is member or manager; member when absent. Ignored by DELETE. + Role string `json:"role,omitempty"` +} + +func (a *API) registerTenancy(mux Mux) { + mux.HandleFunc("GET /api/v1/orgs", a.tenancyTree) + for _, level := range []store.Scope{store.ScopeOrg, store.ScopeTeam, store.ScopeProject} { + path := "/api/v1/" + string(level) + "s" + mux.HandleFunc("POST "+path, a.tenancyCreate(level)) + mux.HandleFunc("PATCH "+path+"/{id}", a.tenancyChange(level)) + mux.HandleFunc("DELETE "+path+"/{id}", a.tenancyDelete(level)) + } + mux.HandleFunc("GET /api/v1/keys", a.keyList) + mux.HandleFunc("POST /api/v1/keys", a.keyCreate) + mux.HandleFunc("GET /api/v1/keys/{id}", a.keyGet) + mux.HandleFunc("DELETE /api/v1/keys/{id}", a.keyRevoke) + mux.HandleFunc("GET /api/v1/users", a.userList) + mux.HandleFunc("GET /api/v1/users/{id}", a.userGet) + mux.HandleFunc("POST /api/v1/users/{id}/memberships", a.membership(true)) + mux.HandleFunc("DELETE /api/v1/users/{id}/memberships", a.membership(false)) +} + +// tenancyReady answers for a control plane without the store, and says +// whether it did. +func (a *API) tenancyReady(w http.ResponseWriter) bool { + if a.tenancy == nil || a.quotas == nil { + writeError(w, http.StatusNotImplemented, "organizations, keys and people need the store, which this control plane runs without") + return false + } + return true +} + +// tenancyStatus is the status a store error answers with. +func tenancyStatus(err error) int { + switch { + case errors.Is(err, store.ErrNotFound): + return http.StatusNotFound + case errors.Is(err, store.ErrDuplicateName), errors.Is(err, store.ErrNotEmpty): + return http.StatusConflict + } + return http.StatusBadRequest +} + +func (a *API) tenancyTree(w http.ResponseWriter, r *http.Request) { + if !a.tenancyReady(w) { + return + } + tree, err := a.tenancy.Tree(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out := []OrgView{} + for _, o := range tree { + ov := OrgView{ID: o.ID, Name: o.Name, Members: o.Members, Teams: []TeamView{}} + for _, t := range o.Teams { + tv := TeamView{ID: t.ID, Name: t.Name, Org: o.ID, Members: t.Members, Projects: []ProjectView{}} + for _, p := range t.Projects { + tv.Projects = append(tv.Projects, ProjectView{ID: p.ID, Name: p.Name, Team: t.ID, + Path: o.Name + "/" + t.Name + "/" + p.Name, Users: p.Users, Keys: p.Keys, ActiveKeys: p.ActiveKeys}) + } + ov.Teams = append(ov.Teams, tv) + } + out = append(out, ov) + } + writeJSON(w, http.StatusOK, out) +} + +// parentOf is the level something at level sits in. +func parentOf(level store.Scope) store.Scope { + if level == store.ScopeProject { + return store.ScopeTeam + } + return store.ScopeOrg +} + +// levelNoun is a level as a sentence says it. +func levelNoun(level store.Scope) string { + switch level { + case store.ScopeOrg: + return "an organization" + case store.ScopeTeam: + return "a team" + } + return "a project" +} + +func (a *API) tenancyCreate(level store.Scope) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !a.tenancyReady(w) { + return + } + var in TenancyCreateRequest + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + in.Name = strings.TrimSpace(in.Name) + if in.Name == "" { + writeError(w, http.StatusBadRequest, "a name is needed") + return + } + ctx := r.Context() + var id, parent string + var err error + if level != store.ScopeOrg { + if in.In == "" { + writeError(w, http.StatusBadRequest, fmt.Sprintf("%s goes in %s: give in", levelNoun(level), levelNoun(parentOf(level)))) + return + } + if _, parent, _, err = a.resolveScope(ctx, string(parentOf(level))+":"+in.In); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + } + switch level { + case store.ScopeOrg: + id, err = a.tenancy.CreateOrg(ctx, in.Name) + case store.ScopeTeam: + id, err = a.tenancy.CreateTeam(ctx, parent, in.Name) + default: + id, err = a.tenancy.CreateProject(ctx, parent, in.Name) + } + if errors.Is(err, store.ErrDuplicateName) { + writeError(w, http.StatusConflict, fmt.Sprintf("there is already %s named %q there", levelNoun(level), in.Name)) + return + } + if err != nil { + writeError(w, tenancyStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusCreated, TenancyItem{ID: id, Name: in.Name, In: parent}) + } +} + +func (a *API) tenancyChange(level store.Scope) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !a.tenancyReady(w) { + return + } + var in TenancyChangeRequest + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + _, id, _, err := a.resolveScope(ctx, string(level)+":"+r.PathValue("id")) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + if in.Name == "" && in.In == "" { + writeError(w, http.StatusBadRequest, "nothing to change: give name, in or both") + return + } + out := TenancyItem{ID: id} + if in.In != "" { + if level == store.ScopeOrg { + writeError(w, http.StatusBadRequest, "an organization is in nothing; only a team or a project moves") + return + } + _, to, _, err := a.resolveScope(ctx, string(parentOf(level))+":"+in.In) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + if level == store.ScopeTeam { + err = a.tenancy.MoveTeam(ctx, id, to) + } else { + err = a.tenancy.MoveProject(ctx, id, to) + } + if err != nil { + writeError(w, tenancyStatus(err), err.Error()) + return + } + out.In = to + } + if name := strings.TrimSpace(in.Name); name != "" { + switch level { + case store.ScopeOrg: + err = a.tenancy.RenameOrg(ctx, id, name) + case store.ScopeTeam: + err = a.tenancy.RenameTeam(ctx, id, name) + default: + err = a.tenancy.RenameProject(ctx, id, name) + } + if err != nil { + writeError(w, tenancyStatus(err), err.Error()) + return + } + out.Name = name + } + writeJSON(w, http.StatusOK, out) + } +} + +func (a *API) tenancyDelete(level store.Scope) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !a.tenancyReady(w) { + return + } + ctx := r.Context() + _, id, _, err := a.resolveScope(ctx, string(level)+":"+r.PathValue("id")) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + switch level { + case store.ScopeOrg: + err = a.tenancy.DeleteOrg(ctx, id) + case store.ScopeTeam: + err = a.tenancy.DeleteTeam(ctx, id) + default: + err = a.tenancy.DeleteProject(ctx, id) + } + if err != nil { + // Only what is empty goes: the store says what is still in it. + writeError(w, tenancyStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true}) + } +} + +// keyState is a key's state at an instant. +func keyState(k store.Key, now time.Time) string { + switch { + case !k.RevokedAt.IsZero(): + return "revoked" + case !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt): + return "expired" + } + return "active" +} + +func keyView(k store.Key, path, email string, now time.Time) KeyView { + return KeyView{ID: k.ID, Name: k.Name, Prefix: k.Prefix, Project: k.ProjectID, ProjectPath: path, + User: k.UserID, UserEmail: email, Band: k.Band, RoutingProfile: k.RoutingProfile, State: keyState(k, now), + CreatedAt: k.CreatedAt, ExpiresAt: k.ExpiresAt, RevokedAt: k.RevokedAt, LastUsedAt: k.LastUsedAt} +} + +func (a *API) keyList(w http.ResponseWriter, r *http.Request) { + if !a.tenancyReady(w) { + return + } + project := "" + if p := r.URL.Query().Get("project"); p != "" { + var err error + if _, project, _, err = a.resolveScope(r.Context(), "project:"+p); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + } + rows, err := a.tenancy.AllKeys(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + now := time.Now() + out := []KeyView{} + for _, k := range rows { + if project == "" || k.ProjectID == project { + out = append(out, keyView(k.Key, k.ProjectPath, k.OwnerEmail, now)) + } + } + writeJSON(w, http.StatusOK, out) +} + +func (a *API) keyGet(w http.ResponseWriter, r *http.Request) { + if !a.tenancyReady(w) { + return + } + rows, err := a.tenancy.AllKeys(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + for _, k := range rows { + if k.ID == r.PathValue("id") { + writeJSON(w, http.StatusOK, keyView(k.Key, k.ProjectPath, k.OwnerEmail, time.Now())) + return + } + } + writeError(w, http.StatusNotFound, "no key "+r.PathValue("id")) +} + +// findUser reads a person by ID or email. +func (a *API) findUser(ctx context.Context, ref string) (store.User, error) { + ref = strings.TrimSpace(ref) + if u, err := a.tenancy.UserByID(ctx, ref); err == nil { + return u, nil + } + if strings.Contains(ref, "@") { + if u, err := a.tenancy.UserByEmail(ctx, ref); err == nil { + return u, nil + } + } + return store.User{}, fmt.Errorf("%w: no person %q", store.ErrNotFound, ref) +} + +func (a *API) keyCreate(w http.ResponseWriter, r *http.Request) { + if !a.tenancyReady(w) { + return + } + var in KeyCreateRequest + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + if strings.TrimSpace(in.Name) == "" { + writeError(w, http.StatusBadRequest, "a key needs a name") + return + } + if in.Project == "" && in.User == "" { + writeError(w, http.StatusBadRequest, "a key belongs to a project, a person, or a person in a project: give project, user or both") + return + } + var owner store.User + var err error + if in.User != "" { + if owner, err = a.findUser(ctx, in.User); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + } + project, path := "", "" + if in.Project != "" { + if _, project, path, err = a.resolveScope(ctx, "project:"+in.Project); err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + } + var expires time.Duration + if in.ExpiresIn != "" { + if expires, err = time.ParseDuration(in.ExpiresIn); err != nil || expires <= 0 { + writeError(w, http.StatusBadRequest, fmt.Sprintf("expires_in %q is not a positive duration such as 720h", in.ExpiresIn)) + return + } + } + secret, k, err := store.NewKey(store.NewID("key-"), strings.TrimSpace(in.Name), project) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + k.CreatedAt = time.Now() + k.Band, k.RoutingProfile = in.Band, in.RoutingProfile + if expires > 0 { + k.ExpiresAt = k.CreatedAt.Add(expires) + } + if owner.ID != "" { + err = a.tenancy.CreateUserKey(ctx, owner.ID, k) + } else { + err = a.tenancy.CreateKey(ctx, k) + } + switch { + case errors.Is(err, store.ErrNotMember): + writeError(w, http.StatusConflict, fmt.Sprintf("%s is not a member of %s; add the membership first", owner.Email, path)) + return + case errors.Is(err, store.ErrKeyTTL): + writeError(w, http.StatusBadRequest, err.Error()) + return + case err != nil: + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + k.UserID = owner.ID + writeJSON(w, http.StatusCreated, KeyCreated{KeyView: keyView(k, path, owner.Email, time.Now()), Secret: secret}) +} + +func (a *API) keyRevoke(w http.ResponseWriter, r *http.Request) { + if !a.tenancyReady(w) { + return + } + id := r.PathValue("id") + if _, err := a.tenancy.KeyByID(r.Context(), id); err != nil { + writeError(w, tenancyStatus(err), err.Error()) + return + } + if err := a.tenancy.RevokeKey(r.Context(), id, time.Now()); err != nil { + writeError(w, tenancyStatus(err), err.Error()) + return + } + // A key held in a gateway's cache keeps working until auth.cache_ttl. + writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true}) +} + +func (a *API) userView(ctx context.Context, u store.User) UserView { + role := u.Role + if u.IsAdmin { + role = "admin" + } + v := UserView{ID: u.ID, Email: u.Email, Name: u.Name, State: string(u.State), Role: role, + Memberships: []MembershipView{}, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt} + if ms, err := a.tenancy.MembershipsOf(ctx, u.ID); err == nil { + for _, m := range ms { + v.Memberships = append(v.Memberships, MembershipView{Target: string(m.Scope) + ":" + m.ScopeID, + Path: m.Path, Role: m.Role, At: m.AddedAt, By: m.AddedBy}) + } + } + return v +} + +func (a *API) userList(w http.ResponseWriter, r *http.Request) { + if !a.tenancyReady(w) { + return + } + us, err := a.tenancy.Users(r.Context(), store.UserState(r.URL.Query().Get("state"))) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out := []UserView{} + for _, u := range us { + out = append(out, a.userView(r.Context(), u)) + } + writeJSON(w, http.StatusOK, out) +} + +func (a *API) userGet(w http.ResponseWriter, r *http.Request) { + if !a.tenancyReady(w) { + return + } + u, err := a.findUser(r.Context(), r.PathValue("id")) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, a.userView(r.Context(), u)) +} + +func (a *API) membership(add bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !a.tenancyReady(w) { + return + } + var in MembershipRequest + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + ctx := r.Context() + u, err := a.findUser(ctx, r.PathValue("id")) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + scope, id, _, err := a.resolveScope(ctx, in.Target) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if add { + role := in.Role + if role == "" { + role = store.RoleMember + } + if role != store.RoleMember && role != store.RoleManager { + writeError(w, http.StatusBadRequest, fmt.Sprintf("role %q: member or manager", role)) + return + } + if err := a.tenancy.AddMembership(ctx, u.ID, scope, id, role, actorOf(r), time.Now()); err != nil { + writeError(w, tenancyStatus(err), err.Error()) + return + } + } else { + // The person's keys in projects they no longer reach are + // revoked with it, by the store. + if _, err := a.tenancy.RemoveMembership(ctx, u.ID, scope, id, time.Now()); err != nil { + if errors.Is(err, store.ErrNotFound) { + err = errors.New(u.Email + " is not a member there") + } + writeError(w, http.StatusNotFound, err.Error()) + return + } + } + writeJSON(w, http.StatusOK, a.userView(ctx, u)) + } +} diff --git a/internal/control/adminapi/tenancy_test.go b/internal/control/adminapi/tenancy_test.go new file mode 100644 index 0000000..96b6c50 --- /dev/null +++ b/internal/control/adminapi/tenancy_test.go @@ -0,0 +1,171 @@ +package adminapi + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +func TestTheTenancyTreeKeysAndMembershipsAreAdministered(t *testing.T) { + ctx := context.Background() + a, _ := setup(t, testModels()) + s, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "s.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + a.SetQuotas(s) + a.SetTenancy(s) + mux := http.NewServeMux() + a.Register(mux) + do := func(method, path, body string, out any) int { + t.Helper() + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body))) + if out != nil && rec.Code < 300 { + if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil { + t.Fatalf("%s %s: %v (%s)", method, path, err, rec.Body.String()) + } + } + if rec.Code >= 300 { + t.Logf("%s %s: %d %s", method, path, rec.Code, strings.TrimSpace(rec.Body.String())) + } + return rec.Code + } + var made TenancyItem + if c := do("POST", "/api/v1/orgs", `{"name":"Acme"}`, &made); c != http.StatusCreated { + t.Fatalf("adding an organization: %d", c) + } + acme := made.ID + if c := do("POST", "/api/v1/orgs", `{"name":"Acme"}`, nil); c != http.StatusConflict { + t.Errorf("a second Acme: %d", c) + } + if c := do("POST", "/api/v1/teams", `{"name":"hr"}`, nil); c != http.StatusBadRequest { + t.Errorf("a team in nothing: %d", c) + } + do("POST", "/api/v1/teams", `{"name":"hr","in":"Acme"}`, &made) + hr := made.ID + if c := do("POST", "/api/v1/projects", `{"name":"crm","in":"Acme/hr"}`, &made); c != http.StatusCreated || made.In != hr { + t.Fatalf("adding a project: %d %v", c, made) + } + crm := made.ID + do("POST", "/api/v1/orgs", `{"name":"Other"}`, &made) + other := made.ID + + // Renamed, and moved with what is in it. + if c := do("PATCH", "/api/v1/projects/"+crm, `{"name":"crm2"}`, nil); c != http.StatusOK { + t.Errorf("renaming: %d", c) + } + if c := do("PATCH", "/api/v1/teams/"+hr, `{"in":"Other"}`, nil); c != http.StatusOK { + t.Errorf("moving: %d", c) + } + if c := do("PATCH", "/api/v1/orgs/"+acme, `{"in":"Other"}`, nil); c != http.StatusBadRequest { + t.Errorf("moving an organization: %d", c) + } + var tree []OrgView + do("GET", "/api/v1/orgs", "", &tree) + if len(tree) != 2 || tree[1].ID != other || len(tree[1].Teams) != 1 || + tree[1].Teams[0].Projects[0].Path != "Other/hr/crm2" { + t.Fatalf("the tree is %+v", tree) + } + + // A project's key: given once, listed without it. + var key KeyCreated + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/keys", + strings.NewReader(`{"name":"svc","project":"Other/hr/crm2","expires_in":"24h"}`))) + if rec.Code != http.StatusCreated { + t.Fatalf("issuing a key: %d %s", rec.Code, rec.Body.String()) + } + _ = json.Unmarshal(rec.Body.Bytes(), &key) + if !strings.HasPrefix(key.Secret, "sk-llmgw-") || key.Project != crm || key.ExpiresAt.IsZero() { + t.Errorf("the key issued: %+v", key) + } + if k, err := s.KeyByHash(ctx, store.HashKey(key.Secret)); err != nil || k.ID != key.ID { + t.Errorf("the key does not authenticate: %v", err) + } + var keys []KeyView + do("GET", "/api/v1/keys?project="+crm, "", &keys) + if len(keys) != 1 || keys[0].ID != key.ID || keys[0].State != "active" { + t.Errorf("the project's keys: %+v", keys) + } + raw := httptest.NewRecorder() + mux.ServeHTTP(raw, httptest.NewRequest("GET", "/api/v1/keys", nil)) + if strings.Contains(raw.Body.String(), key.Secret) || strings.Contains(raw.Body.String(), "secret") { + t.Error("a listing holds the key") + } + if c := do("POST", "/api/v1/keys", `{"name":"x","project":"Other/hr/crm2","expires_in":"soon"}`, nil); c != http.StatusBadRequest { + t.Errorf("a lifetime that is not one: %d", c) + } + if c := do("DELETE", "/api/v1/projects/"+crm, "", nil); c != http.StatusConflict { + t.Errorf("removing a project with a key: %d", c) + } + if c := do("DELETE", "/api/v1/keys/"+key.ID, "", nil); c != http.StatusOK { + t.Errorf("revoking: %d", c) + } + var one KeyView + do("GET", "/api/v1/keys/"+key.ID, "", &one) + if one.State != "revoked" { + t.Errorf("after revoking, %+v", one) + } + + // A person: a key in a project only as its member, revoked when they + // leave it. + if err := s.CreateUser(ctx, store.User{ID: "u1", Email: "ayse@example.com", PasswordHash: "x", + State: store.UserActive, CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + if c := do("POST", "/api/v1/keys", `{"name":"mine","project":"`+crm+`","user":"ayse@example.com"}`, nil); c != http.StatusConflict { + t.Errorf("a key where its person is no member: %d", c) + } + var u UserView + if c := do("POST", "/api/v1/users/ayse@example.com/memberships", `{"target":"project:Other/hr/crm2"}`, &u); c != http.StatusOK || + len(u.Memberships) != 1 || u.Memberships[0].Target != "project:"+crm || u.Memberships[0].Role != "member" { + t.Fatalf("joining: %d %+v", c, u) + } + var mine KeyCreated + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/v1/keys", + strings.NewReader(`{"name":"mine","project":"`+crm+`","user":"u1"}`))) + _ = json.Unmarshal(rec.Body.Bytes(), &mine) + if rec.Code != http.StatusCreated || mine.User != "u1" { + t.Fatalf("a member's key: %d %s", rec.Code, rec.Body.String()) + } + if c := do("DELETE", "/api/v1/users/u1/memberships", `{"target":"project:`+crm+`"}`, &u); c != http.StatusOK || len(u.Memberships) != 0 { + t.Errorf("leaving: %d %+v", c, u) + } + do("GET", "/api/v1/keys/"+mine.ID, "", &one) + if one.State != "revoked" { + t.Errorf("the key of a person who left: %+v", one) + } + if c := do("DELETE", "/api/v1/users/u1/memberships", `{"target":"project:`+crm+`"}`, nil); c != http.StatusNotFound { + t.Errorf("leaving twice: %d", c) + } + var people []UserView + do("GET", "/api/v1/users", "", &people) + if len(people) != 1 || people[0].Email != "ayse@example.com" { + t.Errorf("people: %+v", people) + } + + // Only what is empty is removed. + if c := do("DELETE", "/api/v1/orgs/Acme", "", nil); c != http.StatusOK { + t.Errorf("removing the empty Acme by name: %d", c) + } + if c := do("DELETE", "/api/v1/orgs/"+other, "", nil); c != http.StatusConflict { + t.Errorf("removing an organization with a team: %d", c) + } +} + +func TestTenancyNeedsTheStore(t *testing.T) { + a, _ := setup(t, testModels()) + if c := call(t, a, "GET", "/api/v1/orgs", "", nil); c != http.StatusNotImplemented { + t.Errorf("without a store: %d", c) + } +}