From 14232be8b10c74c51a528e14ef36b2c842d49563 Mon Sep 17 00:00:00 2001 From: dusan Date: Fri, 19 Jun 2026 11:29:40 +0200 Subject: [PATCH 1/3] Add support for aliases Signed-off-by: dusan --- README.md | 4 +- apidocs/grpc-reference.md | 51 ++++++ app/components/app-shell/tenant-switcher.tsx | 4 +- app/components/crud/table/initial-values.ts | 2 +- .../endpoints/api-endpoints-table.tsx | 16 +- .../playground/graphql-playground.tsx | 2 +- app/components/tenants/tenant-create-form.tsx | 20 +- app/lib/crud/resources.ts | 8 +- docs/content/docs/architecture/data-model.mdx | 10 + docs/content/docs/index.mdx | 4 +- docs/content/docs/magistrala-on-atom.mdx | 10 + docs/content/docs/quickstart.mdx | 2 +- migrations/004_entity_resource_aliases.sql | 106 +++++++++++ postman/Atom.postman_collection.json | 2 +- proto/atom/v1/atom.proto | 27 +++ src/authz/engine.rs | 6 +- src/authz/repo.rs | 104 +++++++++-- src/graphql/auth.rs | 2 +- src/graphql/entities.rs | 3 + src/graphql/resources.rs | 2 + src/graphql/tenants.rs | 8 +- src/graphql/types/mod.rs | 22 ++- src/grpc.rs | 67 ++++++- src/identity/handlers.rs | 2 +- src/identity/repo.rs | 28 +-- src/identity/service.rs | 43 +++-- src/models/alias.rs | 111 +++++++++++ src/models/entity.rs | 3 + src/models/mod.rs | 1 + src/models/resource.rs | 3 + src/models/session.rs | 2 +- src/models/tenant.rs | 8 +- src/tenants/repo.rs | 60 +++--- tests/m11_graphql_primitives.rs | 12 +- tests/m18_aliases.rs | 173 ++++++++++++++++++ tests/m3_lifecycle.rs | 2 +- tests/m4_inheritance.rs | 2 +- tests/m5_bootstrap.rs | 2 +- tests/m6_conditions.rs | 2 +- tests/m7_audit.rs | 2 +- tests/m9_profiles.rs | 1 + 41 files changed, 802 insertions(+), 137 deletions(-) create mode 100644 migrations/004_entity_resource_aliases.sql create mode 100644 src/models/alias.rs create mode 100644 tests/m18_aliases.rs diff --git a/README.md b/README.md index 69fd7a4..80806fd 100644 --- a/README.md +++ b/README.md @@ -382,11 +382,11 @@ mutation { mutation { createTenant(input: { name: "factory-a", - route: "factory-a" + alias: "factory-a" }) { id name - route + alias status } } diff --git a/apidocs/grpc-reference.md b/apidocs/grpc-reference.md index 42c049d..36b6bbe 100644 --- a/apidocs/grpc-reference.md +++ b/apidocs/grpc-reference.md @@ -9,11 +9,14 @@ - [CheckRequest](#atom-v1-CheckRequest) - [CheckRequest.ContextEntry](#atom-v1-CheckRequest-ContextEntry) - [CheckResponse](#atom-v1-CheckResponse) + - [ResolveAliasRequest](#atom-v1-ResolveAliasRequest) + - [ResolveAliasResponse](#atom-v1-ResolveAliasResponse) - [ResolveCertificateRequest](#atom-v1-ResolveCertificateRequest) - [ResolveCertificateResponse](#atom-v1-ResolveCertificateResponse) - [RevokeEntityCertificatesRequest](#atom-v1-RevokeEntityCertificatesRequest) - [RevokeEntityCertificatesResponse](#atom-v1-RevokeEntityCertificatesResponse) + - [AliasService](#atom-v1-AliasService) - [AuthService](#atom-v1-AuthService) - [AuthzService](#atom-v1-AuthzService) - [CertificateService](#atom-v1-CertificateService) @@ -113,6 +116,40 @@ + + +### ResolveAliasRequest + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| tenant_id | [string](#string) | | Tenant selector — exactly one of these identifies the domain. tenant_id wins if both are set; tenant_alias is the case-folded tenant slug. | +| tenant_alias | [string](#string) | | | +| object_kind | [string](#string) | | Which table the object alias addresses: "entity" (clients/devices) or "resource" (channels). Anything other than "entity" is treated as a resource. Generic on purpose — no domain/channel vocabulary. | +| object_alias | [string](#string) | | The object's alias slug, unique within the tenant. | + + + + + + + + +### ResolveAliasResponse + + + +| Field | Type | Label | Description | +| ----- | ---- | ----- | ----------- | +| tenant_id | [string](#string) | | | +| object_id | [string](#string) | | | + + + + + + ### ResolveCertificateRequest @@ -184,6 +221,20 @@ + + +### AliasService +AliasService resolves human-friendly alias slugs to canonical UUIDs. +Atom owns the alias registry and its uniqueness; callers (e.g. a message +broker) resolve an alias once, cache the UUID, then authorize by UUID via +AuthzService.Check. Resolution is capability-neutral — it reveals only the +UUIDs; the Check call is the authorization gate. + +| Method Name | Request Type | Response Type | Description | +| ----------- | ------------ | ------------- | ------------| +| ResolveAlias | [ResolveAliasRequest](#atom-v1-ResolveAliasRequest) | [ResolveAliasResponse](#atom-v1-ResolveAliasResponse) | | + + ### AuthService diff --git a/app/components/app-shell/tenant-switcher.tsx b/app/components/app-shell/tenant-switcher.tsx index 9f7eb70..18b4b09 100644 --- a/app/components/app-shell/tenant-switcher.tsx +++ b/app/components/app-shell/tenant-switcher.tsx @@ -32,13 +32,13 @@ const GLOBAL_OPTION: TenantSelection = { id: GLOBAL_TENANT, name: "Global" }; const TENANTS_QUERY = ` query TenantSwitcher { tenants(limit: 100, offset: 0) { - items { id name route } + items { id name alias } } } `; type TenantsData = { - tenants: { items: { id: string; name: string; route: string | null }[] }; + tenants: { items: { id: string; name: string; alias: string | null }[] }; }; export function TenantSwitcher() { diff --git a/app/components/crud/table/initial-values.ts b/app/components/crud/table/initial-values.ts index 3a7618c..9111de5 100644 --- a/app/components/crud/table/initial-values.ts +++ b/app/components/crud/table/initial-values.ts @@ -105,7 +105,7 @@ export function tenantFormInitialValues(row: Row): TenantFormInitialValues { return { id: String(row.id), name: typeof row.name === "string" ? row.name : "", - route: typeof row.route === "string" ? row.route : "", + alias: typeof row.alias === "string" ? row.alias : "", tags: Array.isArray(row.tags) ? row.tags.map((tag) => String(tag)) : [], attributes: row.attributes && typeof row.attributes === "object" diff --git a/app/components/endpoints/api-endpoints-table.tsx b/app/components/endpoints/api-endpoints-table.tsx index c5bb647..9d1343b 100644 --- a/app/components/endpoints/api-endpoints-table.tsx +++ b/app/components/endpoints/api-endpoints-table.tsx @@ -176,14 +176,14 @@ const ENDPOINT_PRESETS: EndpointPreset[] = [ values: { key: "create_tenant", name: "Create Tenant", - description: "Creates a new tenant with a name and optional route.", + description: "Creates a new tenant with a name and optional alias.", method: "POST", path: "/api/custom/tenants", operationKind: "mutation", - graphql: `mutation CreateTenant($input: CreateTenantInput!) {\n createTenant(input: $input) {\n id\n name\n route\n status\n createdAt\n }\n}`, + graphql: `mutation CreateTenant($input: CreateTenantInput!) {\n createTenant(input: $input) {\n id\n name\n alias\n status\n createdAt\n }\n}`, authMode: "caller_context", variablesMapping: JSON.stringify( - { "input.name": "$body.name", "input.route": "$body.route" }, + { "input.name": "$body.name", "input.alias": "$body.alias" }, null, 2, ), @@ -193,7 +193,7 @@ const ENDPOINT_PRESETS: EndpointPreset[] = [ required: ["name"], properties: { name: { type: "string" }, - route: { type: "string" }, + alias: { type: "string" }, }, }, null, @@ -209,17 +209,17 @@ const ENDPOINT_PRESETS: EndpointPreset[] = [ values: { key: "update_tenant", name: "Update Tenant", - description: "Updates a tenant's name or route.", + description: "Updates a tenant's name or alias.", method: "PATCH", path: "/api/custom/tenants/update", operationKind: "mutation", - graphql: `mutation UpdateTenant($id: ID!, $input: UpdateTenantInput!) {\n updateTenant(id: $id, input: $input) {\n id\n name\n route\n status\n updatedAt\n }\n}`, + graphql: `mutation UpdateTenant($id: ID!, $input: UpdateTenantInput!) {\n updateTenant(id: $id, input: $input) {\n id\n name\n alias\n status\n updatedAt\n }\n}`, authMode: "caller_context", variablesMapping: JSON.stringify( { id: "$query.id", "input.name": "$body.name", - "input.route": "$body.route", + "input.alias": "$body.alias", }, null, 2, @@ -229,7 +229,7 @@ const ENDPOINT_PRESETS: EndpointPreset[] = [ type: "object", properties: { name: { type: "string" }, - route: { type: "string" }, + alias: { type: "string" }, }, }, null, diff --git a/app/components/playground/graphql-playground.tsx b/app/components/playground/graphql-playground.tsx index 6470519..101566a 100644 --- a/app/components/playground/graphql-playground.tsx +++ b/app/components/playground/graphql-playground.tsx @@ -58,7 +58,7 @@ const STARTER_OPERATIONS = [ items { id name - route + alias status createdAt } diff --git a/app/components/tenants/tenant-create-form.tsx b/app/components/tenants/tenant-create-form.tsx index d0bcde3..ea4cb8b 100644 --- a/app/components/tenants/tenant-create-form.tsx +++ b/app/components/tenants/tenant-create-form.tsx @@ -27,7 +27,7 @@ const CREATE_TENANT_MUTATION = ` createTenant(input: $input) { id name - route + alias status tags attributes @@ -42,7 +42,7 @@ const UPDATE_TENANT_MUTATION = ` updateTenant(id: $id, input: $input) { id name - route + alias status tags attributes @@ -55,7 +55,7 @@ const UPDATE_TENANT_MUTATION = ` const tenantFormSchema = z .object({ name: z.string().trim().min(1, "Name is required."), - route: z.string().trim(), + alias: z.string().trim(), tags: z.array(z.string().trim().min(1)).superRefine((tags, ctx) => { if (new Set(tags).size !== tags.length) { ctx.addIssue({ @@ -85,14 +85,14 @@ type TenantFormValues = z.infer; export type TenantFormInitialValues = { id: string; name?: string | null; - route?: string | null; + alias?: string | null; tags?: string[] | null; attributes?: unknown; }; const defaultValues: TenantFormValues = { name: "", - route: "", + alias: "", tags: [], attributes: "{}", }; @@ -133,7 +133,7 @@ export function TenantCreateForm({ variables: { input: removeEmptyValues({ name: values.name, - route: values.route, + alias: values.alias, tags: values.tags, attributes: parseAttributesJson(values.attributes), }), @@ -157,7 +157,7 @@ export function TenantCreateForm({ id: tenant.id, input: { name: values.name, - route: values.route || null, + alias: values.alias || null, tags: values.tags, attributes: parseAttributesJson(values.attributes), }, @@ -184,7 +184,7 @@ export function TenantCreateForm({
- + ; label: string; - name: "name" | "route"; + name: "name" | "alias"; required?: boolean; }) { return ( diff --git a/app/lib/crud/resources.ts b/app/lib/crud/resources.ts index e547ddd..bdf158d 100644 --- a/app/lib/crud/resources.ts +++ b/app/lib/crud/resources.ts @@ -55,19 +55,19 @@ export const crudResources: CrudResource[] = [ "Top-level boundaries for entities, resources, groups, roles, and assignments.", icon: Building2, queryName: "tenants", - listQuery: `query Tenants($limit: Int = 50, $offset: Int = 0) { tenants(limit: $limit, offset: $offset) { total items { id name route tags attributes status createdAt updatedAt } } }`, - createMutation: `mutation CreateTenant($input: CreateTenantInput!) { createTenant(input: $input) { id name route tags status createdAt updatedAt } }`, + listQuery: `query Tenants($limit: Int = 50, $offset: Int = 0) { tenants(limit: $limit, offset: $offset) { total items { id name alias tags attributes status createdAt updatedAt } } }`, + createMutation: `mutation CreateTenant($input: CreateTenantInput!) { createTenant(input: $input) { id name alias tags status createdAt updatedAt } }`, formAttributes: true, columns: [ { key: "name", label: "Name", priority: "high" }, - { key: "route", label: "Route", priority: "medium" }, + { key: "alias", label: "Alias", priority: "medium" }, { key: "tags", label: "Tags", priority: "medium" }, { key: "status", label: "Status", priority: "high" }, { key: "createdAt", label: "Created", priority: "low" }, { key: "updatedAt", label: "Updated", priority: "low" }, ], sampleRows: [ - { id: "global", name: "Global", route: "-", status: "active" }, + { id: "global", name: "Global", alias: "-", status: "active" }, ], missing: {}, }, diff --git a/docs/content/docs/architecture/data-model.mdx b/docs/content/docs/architecture/data-model.mdx index 7b24fdb..b255a03 100644 --- a/docs/content/docs/architecture/data-model.mdx +++ b/docs/content/docs/architecture/data-model.mdx @@ -71,6 +71,16 @@ Atom stores security state in Postgres. All primary keys are UUIDs. Most objects | `audit_logs` | Immutable history of security-relevant events. | | `certificate_crl_state` | Cached CRL state for the active mounted certificate issuer. | +## Aliases + +Every `tenants`, `entities`, and `resources` row may carry an optional `alias`: a short, human-friendly handle used in place of the UUID. The UUID stays the canonical identity — aliases are a convenience layer for addressing and never appear as foreign keys, audit subjects, or authorization scope. + +- **Slug shape.** An alias is a lowercase slug of 1–63 characters using `a–z`, `0–9`, and `-`, with no leading or trailing dash. It is case-folded on write, may not be UUID-shaped, and is validated both in the service and by a database `CHECK`. +- **Uniqueness.** Tenant aliases are unique across the platform. Entity and resource aliases are unique **within their tenant**, so the same alias (for example `watermeters`) may be reused in different tenants. +- **Resolution.** `AliasService.ResolveAlias` (gRPC) resolves a tenant alias plus an object alias to the underlying UUIDs in one call, so a caller can address objects by name and then authorize by UUID. Resolution is capability-neutral; the authorization gate is the subsequent `authz` check by UUID. + +Aliases stay an alias, not a replacement: rename one and every UUID-keyed grant, session, and audit row is unaffected. + ## Entity And Credential State Entities are the universal subject type. A user, device, service, workload, and application are all entities with different `kind` values. diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index 30f0b16..fa2d783 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -129,11 +129,11 @@ mutation { mutation { createTenant(input: { name: "factory-a", - route: "factory-a" + alias: "factory-a" }) { id name - route + alias status } } diff --git a/docs/content/docs/magistrala-on-atom.mdx b/docs/content/docs/magistrala-on-atom.mdx index 1e82d8f..02ad015 100644 --- a/docs/content/docs/magistrala-on-atom.mdx +++ b/docs/content/docs/magistrala-on-atom.mdx @@ -22,6 +22,16 @@ Magistrala still handles IoT application behavior: protocols, message routing, s | Role member set | Principal group or role assignment | | Client-channel connection | Role assignment or direct policy | +## Routes And Aliases + +Magistrala addresses messages by route — a friendly path such as `m/ultraviolet/c/watermeters/#` — instead of raw UUIDs. Atom backs this with **aliases**: the domain segment is the tenant alias and the channel segment is the resource alias. + +- Magistrala owns the topic grammar (`m/`, `/c/`, `#`, parsing, routing); Atom only stores and resolves the alias slugs. +- A broker resolves a route once via `AliasService.ResolveAlias` — passing the tenant alias and the channel alias — gets back the tenant and resource UUIDs, caches them, then authorizes each message by UUID with `AuthzService.Check`. +- Aliases are unique per tenant, so two domains can both expose a `watermeters` channel. + +See [Aliases](/architecture/data-model#aliases) for slug rules and uniqueness. + ## Runtime Flow alias (carry data; drop old index) +---------------------------------------------------------------------- + +ALTER TABLE tenants RENAME COLUMN route TO alias; +DROP INDEX IF EXISTS idx_tenants_route; + +---------------------------------------------------------------------- +-- 2. New alias columns +---------------------------------------------------------------------- + +ALTER TABLE entities + ADD COLUMN IF NOT EXISTS alias TEXT; + +ALTER TABLE resources + ADD COLUMN IF NOT EXISTS alias TEXT; + +---------------------------------------------------------------------- +-- 3. Hardening of existing tenant aliases +-- +-- The legacy normalize only trimmed, so existing tenant aliases may not be +-- slug-shaped or may collide once compared case-insensitively. Case-fold first, +-- then fail LOUD (rather than silently dropping data) if anything still violates +-- the slug rule or collides. The operator must clean offending rows first. +---------------------------------------------------------------------- + +UPDATE tenants + SET alias = lower(alias) + WHERE alias IS NOT NULL + AND alias <> lower(alias); + +DO $$ +DECLARE + bad_pattern bigint; + case_dupes bigint; +BEGIN + SELECT count(*) INTO bad_pattern + FROM tenants + WHERE alias IS NOT NULL + AND alias !~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$'; + IF bad_pattern > 0 THEN + RAISE EXCEPTION + 'Migration 004: % tenant alias(es) are not valid slugs (lowercase, no leading/trailing dash, max 63). Fix or clear them before migrating.', + bad_pattern; + END IF; + + SELECT count(*) INTO case_dupes FROM ( + SELECT lower(alias) + FROM tenants + WHERE alias IS NOT NULL + GROUP BY lower(alias) + HAVING count(*) > 1 + ) d; + IF case_dupes > 0 THEN + RAISE EXCEPTION + 'Migration 004: % tenant alias(es) collide case-insensitively. Resolve before migrating.', + case_dupes; + END IF; +END $$; + +---------------------------------------------------------------------- +-- 4. Slug shape constraints (belt-and-suspenders to the app-layer validator) +---------------------------------------------------------------------- + +ALTER TABLE tenants + ADD CONSTRAINT chk_tenants_alias_slug + CHECK (alias IS NULL OR alias ~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$'); + +ALTER TABLE entities + ADD CONSTRAINT chk_entities_alias_slug + CHECK (alias IS NULL OR alias ~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$'); + +ALTER TABLE resources + ADD CONSTRAINT chk_resources_alias_slug + CHECK (alias IS NULL OR alias ~ '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$'); + +---------------------------------------------------------------------- +-- 5. Uniqueness +-- tenants: global single namespace (case-folded) +-- entities/resources: scoped per tenant; NULL tenant_id folded to the zero +-- UUID so global (NULL-tenant) objects share one namespace +-- (matching the roles / 003 guardrail pattern). +---------------------------------------------------------------------- + +CREATE UNIQUE INDEX idx_tenants_alias + ON tenants (lower(alias)) + WHERE alias IS NOT NULL; + +CREATE UNIQUE INDEX idx_entities_alias + ON entities (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid), lower(alias)) + WHERE alias IS NOT NULL; + +CREATE UNIQUE INDEX idx_resources_alias + ON resources (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid), lower(alias)) + WHERE alias IS NOT NULL; diff --git a/postman/Atom.postman_collection.json b/postman/Atom.postman_collection.json index 34f2ae3..21dc6c4 100644 --- a/postman/Atom.postman_collection.json +++ b/postman/Atom.postman_collection.json @@ -3315,7 +3315,7 @@ "value": "" }, { - "key": "route", + "key": "alias", "value": "" }, { diff --git a/proto/atom/v1/atom.proto b/proto/atom/v1/atom.proto index 3be8e60..e8c535b 100644 --- a/proto/atom/v1/atom.proto +++ b/proto/atom/v1/atom.proto @@ -21,6 +21,15 @@ service CertificateService { rpc RevokeEntityCertificates(RevokeEntityCertificatesRequest) returns (RevokeEntityCertificatesResponse); } +// AliasService resolves human-friendly alias slugs to canonical UUIDs. +// Atom owns the alias registry and its uniqueness; callers (e.g. a message +// broker) resolve an alias once, cache the UUID, then authorize by UUID via +// AuthzService.Check. Resolution is capability-neutral — it reveals only the +// UUIDs; the Check call is the authorization gate. +service AliasService { + rpc ResolveAlias(ResolveAliasRequest) returns (ResolveAliasResponse); +} + message CheckRequest { string subject_id = 1; string action = 2; // capability name, e.g. "publish" @@ -76,3 +85,21 @@ message RevokeEntityCertificatesRequest { message RevokeEntityCertificatesResponse { uint64 revoked = 1; } + +message ResolveAliasRequest { + // Tenant selector — exactly one of these identifies the domain. tenant_id + // wins if both are set; tenant_alias is the case-folded tenant slug. + string tenant_id = 1; + string tenant_alias = 2; + // Which table the object alias addresses: "entity" (clients/devices) or + // "resource" (channels). Anything other than "entity" is treated as a + // resource. Generic on purpose — no domain/channel vocabulary. + string object_kind = 3; + // The object's alias slug, unique within the tenant. + string object_alias = 4; +} + +message ResolveAliasResponse { + string tenant_id = 1; + string object_id = 2; +} diff --git a/src/authz/engine.rs b/src/authz/engine.rs index 0bfb914..921bf34 100644 --- a/src/authz/engine.rs +++ b/src/authz/engine.rs @@ -1603,7 +1603,7 @@ mod db_tests { CreateTenant { id: None, name: format!("authz-{}", Uuid::new_v4()), - route: None, + alias: None, tags: vec![], attributes: serde_json::Value::Null, }, @@ -1648,7 +1648,7 @@ mod db_tests { CreateTenant { id: None, name: format!("authz-deny-{}", Uuid::new_v4()), - route: None, + alias: None, tags: vec![], attributes: serde_json::Value::Null, }, @@ -1753,7 +1753,7 @@ mod db_tests { CreateTenant { id: None, name: format!("authz-deleted-{}", Uuid::new_v4()), - route: None, + alias: None, tags: vec![], attributes: serde_json::Value::Null, }, diff --git a/src/authz/repo.rs b/src/authz/repo.rs index a225d6b..0321fa2 100644 --- a/src/authz/repo.rs +++ b/src/authz/repo.rs @@ -25,6 +25,7 @@ use crate::{ ActionAssignmentRule, ActionAssignmentRuleList, CreateActionAssignmentRule, ListActionAssignmentRules, }, + alias::AliasObjectClass, capability::{ Capability, CapabilityApplicability, CapabilityApplicabilityEntry, CapabilityApplicabilityInput, CapabilityApplicabilityList, CreateCapability, @@ -60,15 +61,17 @@ pub async fn create_resource(pool: &PgPool, req: CreateResource) -> Result( - r#"INSERT INTO resources (id, kind, name, tenant_id, owner_id, attributes) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id, kind, name, tenant_id, owner_id, attributes, created_at, updated_at"#, + r#"INSERT INTO resources (id, kind, name, alias, tenant_id, owner_id, attributes) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, kind, name, alias, tenant_id, owner_id, attributes, created_at, updated_at"#, ) .bind(id) .bind(req.kind) .bind(req.name) + .bind(alias) .bind(req.tenant_id) .bind(req.owner_id) .bind(attrs) @@ -84,7 +87,7 @@ pub async fn create_resource(pool: &PgPool, req: CreateResource) -> Result Result { sqlx::query_as::<_, Resource>( - "SELECT id, kind, name, tenant_id, owner_id, attributes, created_at, updated_at FROM resources WHERE id = $1", + "SELECT id, kind, name, alias, tenant_id, owner_id, attributes, created_at, updated_at FROM resources WHERE id = $1", ) .bind(id) .fetch_one(pool) @@ -101,7 +104,7 @@ pub async fn list_resources_by_ids(pool: &PgPool, ids: &[Uuid]) -> Result( - r#"SELECT id, kind, name, tenant_id, owner_id, attributes, created_at, updated_at + r#"SELECT id, kind, name, alias, tenant_id, owner_id, attributes, created_at, updated_at FROM resources WHERE id = ANY($1::uuid[]) ORDER BY array_position($1::uuid[], id)"#, @@ -134,12 +137,12 @@ pub async fn list_resources( JOIN target_groups tg ON tg.id = gh.parent_id WHERE $5::boolean ) - SELECT r.id, r.kind, r.name, r.tenant_id, r.owner_id, r.attributes, r.created_at, r.updated_at + SELECT r.id, r.kind, r.name, r.alias, r.tenant_id, r.owner_id, r.attributes, r.created_at, r.updated_at FROM resources r LEFT JOIN group_resource_parents grp ON grp.resource_id = r.id WHERE ($1::text IS NULL OR r.kind = $1) AND ($2::uuid IS NULL OR r.tenant_id = $2) - AND ($3::text IS NULL OR r.name ILIKE $3 OR r.attributes::text ILIKE $3) + AND ($3::text IS NULL OR r.name ILIKE $3 OR r.alias ILIKE $3 OR r.attributes::text ILIKE $3) AND ($4::uuid IS NULL OR grp.group_id IN (SELECT id FROM target_groups)) ORDER BY r.created_at DESC LIMIT $6 OFFSET $7"#, @@ -169,7 +172,7 @@ pub async fn list_resources( LEFT JOIN group_resource_parents grp ON grp.resource_id = r.id WHERE ($1::text IS NULL OR r.kind = $1) AND ($2::uuid IS NULL OR r.tenant_id = $2) - AND ($3::text IS NULL OR r.name ILIKE $3 OR r.attributes::text ILIKE $3) + AND ($3::text IS NULL OR r.name ILIKE $3 OR r.alias ILIKE $3 OR r.attributes::text ILIKE $3) AND ($4::uuid IS NULL OR grp.group_id IN (SELECT id FROM target_groups))"#, ) .bind(kind) @@ -195,18 +198,21 @@ pub async fn update_resource( .and_then(|attrs| attrs.get("parent_group_id")) .map(parent_group_id_from_value) .transpose()?; + let alias = crate::models::alias::validate_alias_opt(req.alias)?; let mut tx = pool.begin().await.map_err(db_err)?; let resource = sqlx::query_as::<_, Resource>( r#"UPDATE resources SET name = COALESCE($2, name), attributes = COALESCE($3, attributes), + alias = COALESCE($4, alias), updated_at = now() WHERE id = $1 - RETURNING id, kind, name, tenant_id, owner_id, attributes, created_at, updated_at"#, + RETURNING id, kind, name, alias, tenant_id, owner_id, attributes, created_at, updated_at"#, ) .bind(id) .bind(req.name) .bind(req.attributes) + .bind(alias) .fetch_one(&mut *tx) .await .map_err(|e| match e { @@ -237,6 +243,80 @@ pub async fn delete_resource(pool: &PgPool, id: Uuid) -> Result<(), AppError> { Ok(()) } +/// The UUIDs a alias path resolves to. +#[derive(Debug, Clone, Copy)] +pub struct ResolvedAlias { + pub tenant_id: Uuid, + pub object_id: Uuid, +} + +/// Resolve a human alias path to canonical UUIDs. +/// +/// Two-level: first resolve the tenant (by id, or case-folded `alias`), then the +/// object (entity or resource) by its case-folded `alias` within that tenant. +/// Resolution is capability-neutral — it reveals only the UUIDs; the actual +/// authorization gate is the subsequent `authz` check by UUID. Returns +/// `NotFound` if either level is missing. +pub async fn resolve_alias( + pool: &PgPool, + tenant_id: Option, + tenant_alias: Option<&str>, + class: AliasObjectClass, + object_alias: &str, +) -> Result { + let tenant_id = match (tenant_id, tenant_alias) { + (Some(id), _) => id, + (None, Some(alias)) => { + sqlx::query_scalar::<_, Uuid>("SELECT id FROM tenants WHERE lower(alias) = lower($1)") + .bind(alias) + .fetch_optional(pool) + .await + .map_err(db_err)? + .ok_or_else(|| AppError::not_found(format!("tenant alias '{alias}' not found")))? + } + (None, None) => { + return Err(AppError::bad_request( + "provide tenant_id or tenant_alias to resolve a alias", + )) + } + }; + + let object_alias = object_alias.trim().to_ascii_lowercase(); + if object_alias.is_empty() { + return Err(AppError::bad_request("object_alias must not be empty")); + } + + let sql = match class { + AliasObjectClass::Entity => { + "SELECT id FROM entities \ + WHERE COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid) = $1 \ + AND lower(alias) = $2" + } + AliasObjectClass::Resource => { + "SELECT id FROM resources \ + WHERE COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid) = $1 \ + AND lower(alias) = $2" + } + }; + + let object_id = sqlx::query_scalar::<_, Uuid>(sql) + .bind(tenant_id) + .bind(&object_alias) + .fetch_optional(pool) + .await + .map_err(db_err)? + .ok_or_else(|| { + AppError::not_found(format!( + "alias '{object_alias}' not found in tenant {tenant_id}" + )) + })?; + + Ok(ResolvedAlias { + tenant_id, + object_id, + }) +} + pub async fn get_resource_parent_group( pool: &PgPool, resource_id: Uuid, @@ -3327,7 +3407,7 @@ pub async fn entity_access( params: AccessQuery, ) -> Result { let entity = sqlx::query_as::<_, Entity>( - r#"SELECT id, kind, name, tenant_id, profile_id, profile_version_id, + r#"SELECT id, kind, name, alias, tenant_id, profile_id, profile_version_id, status, attributes, created_at, updated_at FROM entities WHERE id = $1"#, @@ -4332,7 +4412,7 @@ pub async fn effective_capabilities( ) -> Result { use sqlx::Row; let entity = sqlx::query_as::<_, Entity>( - r#"SELECT id, kind, name, tenant_id, profile_id, profile_version_id, + r#"SELECT id, kind, name, alias, tenant_id, profile_id, profile_version_id, status, attributes, created_at, updated_at FROM entities WHERE id = $1"#, @@ -4698,7 +4778,7 @@ pub async fn unprotected_resources( let limit = params.limit.clamp(1, 200); let offset = params.offset.max(0); let items = sqlx::query_as::<_, Resource>( - r#"SELECT id, kind, name, tenant_id, owner_id, attributes, created_at, updated_at + r#"SELECT id, kind, name, alias, tenant_id, owner_id, attributes, created_at, updated_at FROM resources r WHERE ($1::uuid IS NULL OR tenant_id = $1) AND ($2::text IS NULL OR kind = $2) diff --git a/src/graphql/auth.rs b/src/graphql/auth.rs index ff985b2..1b69aff 100644 --- a/src/graphql/auth.rs +++ b/src/graphql/auth.rs @@ -58,7 +58,7 @@ impl AuthMutation { &input.identifier, &input.secret, parse_optional_id(input.tenant_id, "tenantId")?, - input.tenant_route.as_deref(), + input.tenant_alias.as_deref(), ) .await .map_err(gql_error)?; diff --git a/src/graphql/entities.rs b/src/graphql/entities.rs index 1707875..30c308b 100644 --- a/src/graphql/entities.rs +++ b/src/graphql/entities.rs @@ -155,6 +155,7 @@ impl EntityMutation { "profileVersionId", )?, name: input.name, + alias: input.alias, tenant_id, attributes: input.attributes, }, @@ -194,6 +195,7 @@ impl EntityMutation { entity_model::UpdateEntity { name: input.name, kind: parse_optional_entity_kind(input.kind), + alias: input.alias, tenant_id: parse_optional_id(input.tenant_id, "tenantId")?, profile_id: parse_optional_id(input.profile_id, "profileId")?, profile_version_id: parse_optional_id( @@ -385,6 +387,7 @@ async fn change_entity_status( entity_model::UpdateEntity { name: None, kind: None, + alias: None, tenant_id: None, profile_id: None, profile_version_id: None, diff --git a/src/graphql/resources.rs b/src/graphql/resources.rs index 356ae3c..3f6d6d3 100644 --- a/src/graphql/resources.rs +++ b/src/graphql/resources.rs @@ -134,6 +134,7 @@ impl ResourceMutation { id: parse_optional_id(input.id, "id")?, kind: input.kind, name: input.name, + alias: input.alias, tenant_id, owner_id: parse_optional_id(input.owner_id, "ownerId")?, attributes: input.attributes.unwrap_or(serde_json::Value::Null), @@ -172,6 +173,7 @@ impl ResourceMutation { id, UpdateResource { name: input.name, + alias: input.alias, attributes: input.attributes, }, ) diff --git a/src/graphql/tenants.rs b/src/graphql/tenants.rs index 92d1a30..7ff93ec 100644 --- a/src/graphql/tenants.rs +++ b/src/graphql/tenants.rs @@ -35,7 +35,7 @@ impl TenantQuery { ctx: &Context<'_>, q: Option, name: Option, - route: Option, + alias: Option, status: Option, limit: Option, offset: Option, @@ -45,7 +45,7 @@ impl TenantQuery { let params = ListTenants { q, name, - route, + alias, status: parse_optional_tenant_status(status), limit: limit.map(i64::from).unwrap_or(20), offset: offset.map(i64::from).unwrap_or(0), @@ -245,7 +245,7 @@ impl TenantMutation { tenant_model::CreateTenant { id: parse_optional_id(input.id, "id")?, name: input.name, - route: input.route, + alias: input.alias, tags: input.tags.unwrap_or_default(), attributes: input.attributes.unwrap_or(serde_json::Value::Null), }, @@ -281,7 +281,7 @@ impl TenantMutation { tenant_id, tenant_model::UpdateTenant { name: input.name, - route: input.route, + alias: input.alias, tags: input.tags, attributes: input.attributes, }, diff --git a/src/graphql/types/mod.rs b/src/graphql/types/mod.rs index 165da0a..95b2bee 100644 --- a/src/graphql/types/mod.rs +++ b/src/graphql/types/mod.rs @@ -218,6 +218,10 @@ impl Entity { &self.0.name } + async fn alias(&self) -> Option<&str> { + self.0.alias.as_deref() + } + async fn tenant_id(&self) -> Option { self.0.tenant_id.map(id) } @@ -330,8 +334,8 @@ impl Tenant { &self.0.name } - async fn route(&self) -> Option<&str> { - self.0.route.as_deref() + async fn alias(&self) -> Option<&str> { + self.0.alias.as_deref() } async fn status(&self) -> GqlTenantStatus { @@ -432,6 +436,10 @@ impl Resource { self.0.name.as_deref() } + async fn alias(&self) -> Option<&str> { + self.0.alias.as_deref() + } + async fn tenant_id(&self) -> Option { self.0.tenant_id.map(id) } @@ -1393,7 +1401,7 @@ pub struct LoginInput { pub identifier: String, pub secret: String, pub tenant_id: Option, - pub tenant_route: Option, + pub tenant_alias: Option, #[graphql(default = "password")] pub kind: String, } @@ -1440,6 +1448,7 @@ pub struct CreateEntityInput { pub profile_version_id: Option, pub kind: Option, pub name: String, + pub alias: Option, pub tenant_id: Option, pub attributes: Value, } @@ -1448,6 +1457,7 @@ pub struct CreateEntityInput { pub struct UpdateEntityInput { pub name: Option, pub kind: Option, + pub alias: Option, pub tenant_id: Option, pub profile_id: Option, pub profile_version_id: Option, @@ -1459,7 +1469,7 @@ pub struct UpdateEntityInput { pub struct CreateTenantInput { pub id: Option, pub name: String, - pub route: Option, + pub alias: Option, pub tags: Option>, pub attributes: Option, } @@ -1467,7 +1477,7 @@ pub struct CreateTenantInput { #[derive(InputObject)] pub struct UpdateTenantInput { pub name: Option, - pub route: Option, + pub alias: Option, pub tags: Option>, pub attributes: Option, } @@ -1491,6 +1501,7 @@ pub struct CreateResourceInput { pub id: Option, pub kind: String, pub name: Option, + pub alias: Option, pub tenant_id: Option, pub owner_id: Option, pub attributes: Option, @@ -1499,6 +1510,7 @@ pub struct CreateResourceInput { #[derive(InputObject)] pub struct UpdateResourceInput { pub name: Option, + pub alias: Option, pub attributes: Option, } diff --git a/src/grpc.rs b/src/grpc.rs index a7d1f00..ef89cd2 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -11,9 +11,9 @@ use uuid::Uuid; use crate::{ audit, auth::{authenticate_token, require_any_capability, scope_for_tenant, AuthContext, Scope}, - authz::{access, engine}, + authz::{access, engine, repo}, certs, - models::{enums::AuditOutcome, policy::AuthzRequest}, + models::{alias::AliasObjectClass, enums::AuditOutcome, policy::AuthzRequest}, state::{AppState, GrpcRuntimeStatus}, }; @@ -23,12 +23,13 @@ pub mod proto { } use proto::{ + alias_service_server::{AliasService, AliasServiceServer}, auth_service_server::{AuthService, AuthServiceServer}, authz_service_server::{AuthzService, AuthzServiceServer}, certificate_service_server::{CertificateService, CertificateServiceServer}, - AuthenticateRequest, AuthenticateResponse, CheckRequest, CheckResponse, - ResolveCertificateRequest, ResolveCertificateResponse, RevokeEntityCertificatesRequest, - RevokeEntityCertificatesResponse, + AuthenticateRequest, AuthenticateResponse, CheckRequest, CheckResponse, ResolveAliasRequest, + ResolveAliasResponse, ResolveCertificateRequest, ResolveCertificateResponse, + RevokeEntityCertificatesRequest, RevokeEntityCertificatesResponse, }; // ─── AuthzService ───────────────────────────────────────────────────────────── @@ -259,6 +260,56 @@ impl CertificateService for AtomCertificates { } } +// ─── AliasService ────────────────────────────────────────────────────────────── + +struct AtomAlias { + state: AppState, +} + +#[tonic::async_trait] +impl AliasService for AtomAlias { + async fn resolve_alias( + &self, + request: Request, + ) -> Result, Status> { + // Authenticate the caller; resolution itself is capability-neutral — the + // subsequent AuthzService.Check by UUID is the authorization gate. + let _auth = auth_context_from_metadata(&self.state, request.metadata()).await?; + let req = request.into_inner(); + + let tenant_id = if req.tenant_id.is_empty() { + None + } else { + Some( + Uuid::parse_str(&req.tenant_id) + .map_err(|_| Status::invalid_argument("invalid tenant_id: expected UUID"))?, + ) + }; + let tenant_alias = (!req.tenant_alias.is_empty()).then_some(req.tenant_alias.as_str()); + + let class = if req.object_kind.eq_ignore_ascii_case("entity") { + AliasObjectClass::Entity + } else { + AliasObjectClass::Resource + }; + + let resolved = repo::resolve_alias( + &self.state.pool, + tenant_id, + tenant_alias, + class, + &req.object_alias, + ) + .await + .map_err(Status::from)?; + + Ok(Response::new(ResolveAliasResponse { + tenant_id: resolved.tenant_id.to_string(), + object_id: resolved.object_id.to_string(), + })) + } +} + // ─── Server ─────────────────────────────────────────────────────────────────── pub async fn bind_listener(addr: SocketAddr) -> std::io::Result { @@ -289,6 +340,9 @@ pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> health_reporter .set_serving::>() .await; + health_reporter + .set_serving::>() + .await; state .set_grpc_status(GrpcRuntimeStatus::serving(addr.to_string())) @@ -305,6 +359,9 @@ pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> .add_service(CertificateServiceServer::new(AtomCertificates { state: state.clone(), })) + .add_service(AliasServiceServer::new(AtomAlias { + state: state.clone(), + })) .serve_with_incoming(incoming) .await; diff --git a/src/identity/handlers.rs b/src/identity/handlers.rs index 0eb7a9a..3c131c4 100644 --- a/src/identity/handlers.rs +++ b/src/identity/handlers.rs @@ -133,7 +133,7 @@ pub async fn login( &req.identifier, &req.secret, req.tenant_id, - req.tenant_route.as_deref(), + req.tenant_alias.as_deref(), ) .await?; let cookie = auth_cookie( diff --git a/src/identity/repo.rs b/src/identity/repo.rs index 3afa35c..395ad99 100644 --- a/src/identity/repo.rs +++ b/src/identity/repo.rs @@ -31,18 +31,20 @@ pub async fn create_entity(pool: &PgPool, req: CreateEntity) -> Result( r#"INSERT INTO entities - (id, kind, name, tenant_id, profile_id, profile_version_id, attributes) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, kind, name, tenant_id, profile_id, profile_version_id, + (id, kind, name, alias, tenant_id, profile_id, profile_version_id, attributes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, kind, name, alias, tenant_id, profile_id, profile_version_id, status, attributes, created_at, updated_at"#, ) .bind(id) .bind(kind) .bind(req.name) + .bind(alias) .bind(req.tenant_id) .bind(profile_id) .bind(profile_version_id) @@ -81,7 +83,7 @@ pub async fn add_authenticated_user_membership_in_tx( pub async fn get_entity(pool: &PgPool, id: Uuid) -> Result { sqlx::query_as::<_, Entity>( - r#"SELECT id, kind, name, tenant_id, profile_id, profile_version_id, + r#"SELECT id, kind, name, alias, tenant_id, profile_id, profile_version_id, status, attributes, created_at, updated_at FROM entities WHERE id = $1"#, @@ -101,7 +103,7 @@ pub async fn list_entities_by_ids(pool: &PgPool, ids: &[Uuid]) -> Result( - r#"SELECT id, kind, name, tenant_id, profile_id, profile_version_id, + r#"SELECT id, kind, name, alias, tenant_id, profile_id, profile_version_id, status, attributes, created_at, updated_at FROM entities WHERE id = ANY($1::uuid[]) @@ -133,7 +135,7 @@ pub async fn list_entities(pool: &PgPool, params: ListEntities) -> Result Result Result Result validate_existing_entity_attributes(pool, id, attrs).await?; } + let alias = crate::models::alias::validate_alias_opt(req.alias)?; + let mut tx = pool.begin().await.map_err(db_err)?; let entity = sqlx::query_as::<_, Entity>( r#"UPDATE entities @@ -213,9 +217,10 @@ pub async fn update_entity(pool: &PgPool, id: Uuid, req: UpdateEntity) -> Result profile_version_id = COALESCE($6, profile_version_id), status = COALESCE($7, status), attributes = COALESCE($8, attributes), + alias = COALESCE($9, alias), updated_at = now() WHERE id = $1 - RETURNING id, kind, name, tenant_id, profile_id, profile_version_id, + RETURNING id, kind, name, alias, tenant_id, profile_id, profile_version_id, status, attributes, created_at, updated_at"#, ) .bind(id) @@ -226,6 +231,7 @@ pub async fn update_entity(pool: &PgPool, id: Uuid, req: UpdateEntity) -> Result .bind(req.profile_version_id) .bind(req.status) .bind(attributes) + .bind(alias) .fetch_one(&mut *tx) .await .map_err(|e| match e { @@ -925,7 +931,7 @@ pub async fn remove_group_member( pub async fn list_group_members(pool: &PgPool, group_id: Uuid) -> Result, AppError> { sqlx::query_as::<_, Entity>( - r#"SELECT e.id, e.kind, e.name, e.tenant_id, e.profile_id, e.profile_version_id, + r#"SELECT e.id, e.kind, e.name, e.alias, e.tenant_id, e.profile_id, e.profile_version_id, e.status, e.attributes, e.created_at, e.updated_at FROM entities e JOIN principal_group_members gm ON gm.entity_id = e.id @@ -970,7 +976,7 @@ pub async fn create_ownership( pub async fn list_owned(pool: &PgPool, owner_id: Uuid) -> Result, AppError> { sqlx::query_as::<_, Entity>( - r#"SELECT e.id, e.kind, e.name, e.tenant_id, e.profile_id, e.profile_version_id, + r#"SELECT e.id, e.kind, e.name, e.alias, e.tenant_id, e.profile_id, e.profile_version_id, e.status, e.attributes, e.created_at, e.updated_at FROM entities e JOIN ownerships o ON o.owned_id = e.id diff --git a/src/identity/service.rs b/src/identity/service.rs index 6b83280..80fb3b9 100644 --- a/src/identity/service.rs +++ b/src/identity/service.rs @@ -86,7 +86,7 @@ pub async fn login_password_with_tenant( identifier: &str, secret: &str, tenant_id: Option, - tenant_route: Option<&str>, + tenant_alias: Option<&str>, ) -> Result { let result = do_login_password( pool, @@ -95,7 +95,7 @@ pub async fn login_password_with_tenant( identifier, secret, tenant_id, - tenant_route, + tenant_alias, ) .await; @@ -133,9 +133,9 @@ async fn do_login_password( identifier: &str, secret: &str, requested_tenant_id: Option, - tenant_route: Option<&str>, + tenant_alias: Option<&str>, ) -> Result { - let login_tenant_id = resolve_login_tenant(pool, requested_tenant_id, tenant_route).await?; + let login_tenant_id = resolve_login_tenant(pool, requested_tenant_id, tenant_alias).await?; let attempt_identifier = login_attempt_identifier(identifier); if let Err(err) = ensure_login_not_throttled( pool, @@ -947,7 +947,7 @@ async fn login_entity_row( } if tenant_id.is_none() && rows.len() > 1 { return Err(AppError::unauthorized( - "tenant_id or tenant_route required for this identifier", + "tenant_id or tenant_alias required for this identifier", )); } Ok(rows.remove(0)) @@ -956,29 +956,29 @@ async fn login_entity_row( async fn resolve_login_tenant( pool: &PgPool, tenant_id: Option, - tenant_route: Option<&str>, + tenant_alias: Option<&str>, ) -> Result, AppError> { - let tenant_route = normalize_route(tenant_route); - validate_tenant_selector(tenant_id, tenant_route)?; + let tenant_alias = normalize_alias(tenant_alias); + validate_tenant_selector(tenant_id, tenant_alias.as_deref())?; use sqlx::Row; - let Some(row) = (match (tenant_id, tenant_route) { + let Some(row) = (match (tenant_id, tenant_alias) { (Some(tenant_id), None) => { sqlx::query("SELECT id, status FROM tenants WHERE id = $1") .bind(tenant_id) .fetch_optional(pool) .await } - (None, Some(tenant_route)) => { - sqlx::query("SELECT id, status FROM tenants WHERE route = $1") - .bind(tenant_route) + (None, Some(tenant_alias)) => { + sqlx::query("SELECT id, status FROM tenants WHERE lower(alias) = $1") + .bind(tenant_alias) .fetch_optional(pool) .await } (None, None) => return Ok(None), (Some(_), Some(_)) => { return Err(AppError::bad_request( - "provide either tenant_id or tenant_route, not both", + "provide either tenant_id or tenant_alias, not both", )) } }) @@ -1477,17 +1477,24 @@ fn normalize_json_object(value: Value) -> Value { } } -fn normalize_route(route: Option<&str>) -> Option<&str> { - route.map(str::trim).filter(|route| !route.is_empty()) +/// Normalize a alias for *lookup* (login/selector): trim, drop empty, and +/// case-fold so it matches the `lower(alias)` unique index. Lookup does not +/// reject malformed slugs (a non-matching alias simply finds no tenant); strict +/// slug validation happens on the write path via `models::alias::validate_alias`. +fn normalize_alias(alias: Option<&str>) -> Option { + alias + .map(str::trim) + .filter(|alias| !alias.is_empty()) + .map(str::to_ascii_lowercase) } fn validate_tenant_selector( tenant_id: Option, - tenant_route: Option<&str>, + tenant_alias: Option<&str>, ) -> Result<(), AppError> { - if tenant_id.is_some() && tenant_route.is_some() { + if tenant_id.is_some() && tenant_alias.is_some() { return Err(AppError::bad_request( - "provide either tenant_id or tenant_route, not both", + "provide either tenant_id or tenant_alias, not both", )); } Ok(()) diff --git a/src/models/alias.rs b/src/models/alias.rs new file mode 100644 index 0000000..b206f42 --- /dev/null +++ b/src/models/alias.rs @@ -0,0 +1,111 @@ +//! Alias slug validation. +//! +//! Aliases are human-friendly, case-folded handles over UUIDs (tenant domains, +//! resource channels, entity clients). They stay an alias, never a replacement: +//! the UUID remains the canonical identity. A valid alias is a lowercase slug +//! `[a-z0-9][a-z0-9-]{0,62}` that does not end in `-` and is not UUID-shaped, so +//! alias-addressing and id-addressing can never collide. + +use crate::error::AppError; + +/// Maximum slug length (DNS-label-like). +pub const MAX_ALIAS_LEN: usize = 63; + +/// Which addressable table an alias resolves against. Aliases are unique per +/// `(tenant, table)`, so resolution must say whether it wants an entity +/// (client/device) or a resource (channel). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AliasObjectClass { + Entity, + Resource, +} + +/// Validate and normalize a single alias slug. Input is trimmed and lowercased +/// before validation; the normalized form is returned. +pub fn validate_alias(input: &str) -> Result { + let alias = input.trim().to_ascii_lowercase(); + + if alias.is_empty() { + return Err(AppError::bad_request("alias must not be empty")); + } + if alias.len() > MAX_ALIAS_LEN { + return Err(AppError::bad_request(format!( + "alias must be at most {MAX_ALIAS_LEN} characters" + ))); + } + if is_uuid_shaped(&alias) { + return Err(AppError::bad_request( + "alias must not be UUID-shaped (use the id directly to address by UUID)", + )); + } + if !alias + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(AppError::bad_request( + "alias may contain only lowercase letters, digits, and '-'", + )); + } + if !alias.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) { + return Err(AppError::bad_request( + "alias must start with a letter or digit", + )); + } + if alias.ends_with('-') { + return Err(AppError::bad_request("alias must not end with '-'")); + } + + Ok(alias) +} + +/// Validate an optional alias. Empty/whitespace is treated as absent (`None`); +/// any non-empty value must be a valid slug. +pub fn validate_alias_opt(alias: Option) -> Result, AppError> { + match alias { + Some(a) if a.trim().is_empty() => Ok(None), + Some(a) => validate_alias(&a).map(Some), + None => Ok(None), + } +} + +fn is_uuid_shaped(s: &str) -> bool { + uuid::Uuid::parse_str(s).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_simple_slugs() { + assert_eq!(validate_alias("watermeters").unwrap(), "watermeters"); + assert_eq!(validate_alias("Water-Meters").unwrap(), "water-meters"); + assert_eq!(validate_alias(" ultraviolet ").unwrap(), "ultraviolet"); + assert_eq!(validate_alias("a1-b2-c3").unwrap(), "a1-b2-c3"); + } + + #[test] + fn rejects_invalid_slugs() { + assert!(validate_alias("").is_err()); + assert!(validate_alias("-leading").is_err()); + assert!(validate_alias("trailing-").is_err()); + assert!(validate_alias("has space").is_err()); + assert!(validate_alias("emoji🦀").is_err()); + assert!(validate_alias(&"x".repeat(64)).is_err()); + } + + #[test] + fn rejects_uuid_shaped() { + assert!(validate_alias("465358f9-07f4-4ea0-8cbb-2abc654442bd").is_err()); + } + + #[test] + fn optional_treats_blank_as_absent() { + assert_eq!(validate_alias_opt(None).unwrap(), None); + assert_eq!(validate_alias_opt(Some(" ".into())).unwrap(), None); + assert_eq!( + validate_alias_opt(Some("chan".into())).unwrap(), + Some("chan".to_string()) + ); + } +} diff --git a/src/models/entity.rs b/src/models/entity.rs index aec0f6c..a5df8d8 100644 --- a/src/models/entity.rs +++ b/src/models/entity.rs @@ -10,6 +10,7 @@ pub struct Entity { pub id: Uuid, pub kind: EntityKind, pub name: String, + pub alias: Option, pub tenant_id: Option, pub profile_id: Option, pub profile_version_id: Option, @@ -26,6 +27,7 @@ pub struct CreateEntity { pub profile_id: Option, pub profile_version_id: Option, pub name: String, + pub alias: Option, pub tenant_id: Option, #[serde(default)] pub attributes: Value, @@ -35,6 +37,7 @@ pub struct CreateEntity { pub struct UpdateEntity { pub name: Option, pub kind: Option, + pub alias: Option, pub tenant_id: Option, pub profile_id: Option, pub profile_version_id: Option, diff --git a/src/models/mod.rs b/src/models/mod.rs index a051584..062adb4 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,5 +1,6 @@ pub mod access; pub mod action_assignment_rule; +pub mod alias; pub mod api_endpoint; pub mod capability; pub mod entity; diff --git a/src/models/resource.rs b/src/models/resource.rs index 66c1569..75e5e76 100644 --- a/src/models/resource.rs +++ b/src/models/resource.rs @@ -8,6 +8,7 @@ pub struct Resource { pub id: Uuid, pub kind: String, pub name: Option, + pub alias: Option, pub tenant_id: Option, pub owner_id: Option, pub attributes: Value, @@ -20,6 +21,7 @@ pub struct CreateResource { pub id: Option, pub kind: String, pub name: Option, + pub alias: Option, pub tenant_id: Option, pub owner_id: Option, #[serde(default)] @@ -29,6 +31,7 @@ pub struct CreateResource { #[derive(Debug, Deserialize)] pub struct UpdateResource { pub name: Option, + pub alias: Option, pub attributes: Option, } diff --git a/src/models/session.rs b/src/models/session.rs index e376f89..838d828 100644 --- a/src/models/session.rs +++ b/src/models/session.rs @@ -31,7 +31,7 @@ pub struct LoginRequest { pub identifier: String, pub secret: String, pub tenant_id: Option, - pub tenant_route: Option, + pub tenant_alias: Option, #[serde(default = "default_kind")] pub kind: CredentialKind, } diff --git a/src/models/tenant.rs b/src/models/tenant.rs index 7ab2b46..2015428 100644 --- a/src/models/tenant.rs +++ b/src/models/tenant.rs @@ -9,7 +9,7 @@ use super::enums::TenantStatus; pub struct Tenant { pub id: Uuid, pub name: String, - pub route: Option, + pub alias: Option, pub status: TenantStatus, pub tags: Vec, pub attributes: Value, @@ -23,7 +23,7 @@ pub struct Tenant { pub struct CreateTenant { pub id: Option, pub name: String, - pub route: Option, + pub alias: Option, #[serde(default)] pub tags: Vec, #[serde(default)] @@ -33,7 +33,7 @@ pub struct CreateTenant { #[derive(Debug, Deserialize)] pub struct UpdateTenant { pub name: Option, - pub route: Option, + pub alias: Option, pub tags: Option>, pub attributes: Option, } @@ -42,7 +42,7 @@ pub struct UpdateTenant { pub struct ListTenants { pub q: Option, pub name: Option, - pub route: Option, + pub alias: Option, pub status: Option, #[serde(default = "default_limit")] pub limit: i64, diff --git a/src/tenants/repo.rs b/src/tenants/repo.rs index 92b8de1..d6523f6 100644 --- a/src/tenants/repo.rs +++ b/src/tenants/repo.rs @@ -17,7 +17,7 @@ use crate::{ }; const TENANT_COLS: &str = - "id, name, route, status, tags, attributes, created_by, updated_by, created_at, updated_at"; + "id, name, alias, status, tags, attributes, created_by, updated_by, created_at, updated_at"; const INVITATION_COLS: &str = "ti.id, ti.tenant_id, ti.invitee_user_id, ti.invitee_email, ti.invited_by, ti.role_id, r.name AS role_name, ti.accepted_at, ti.rejected_at, @@ -86,19 +86,20 @@ async fn create_tenant_in_tx( created_by: Option, ) -> Result { let id = req.id.unwrap_or_else(Uuid::new_v4); + let alias = crate::models::alias::validate_alias_opt(req.alias)?; let attrs = if req.attributes.is_null() { serde_json::json!({}) } else { req.attributes }; sqlx::query_as::<_, Tenant>(&format!( - r#"INSERT INTO tenants (id, name, route, tags, attributes, created_by, updated_by) + r#"INSERT INTO tenants (id, name, alias, tags, attributes, created_by, updated_by) VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING {TENANT_COLS}"#, )) .bind(id) .bind(req.name) - .bind(req.route) + .bind(alias) .bind(&req.tags) .bind(attrs) .bind(created_by) @@ -233,21 +234,21 @@ pub async fn list_tenants(pool: &PgPool, params: ListTenants) -> Result(&format!( r#"SELECT {TENANT_COLS} FROM tenants WHERE ($1::text IS NULL OR name = $1) - AND ($2::text IS NULL OR route = $2) + AND ($2::text IS NULL OR lower(alias) = lower($2)) AND ($3::text IS NULL OR status = $3) - AND ($4::text IS NULL OR name ILIKE $4 OR route ILIKE $4 OR array_to_string(tags, ',') ILIKE $4 OR attributes::text ILIKE $4) + AND ($4::text IS NULL OR name ILIKE $4 OR alias ILIKE $4 OR array_to_string(tags, ',') ILIKE $4 OR attributes::text ILIKE $4) ORDER BY created_at DESC LIMIT $5 OFFSET $6"#, )) .bind(name.clone()) - .bind(route.clone()) + .bind(alias.clone()) .bind(status.clone()) .bind(q.clone()) .bind(limit) @@ -259,12 +260,12 @@ pub async fn list_tenants(pool: &PgPool, params: ListTenants) -> Result(&format!( r#"SELECT {TENANT_COLS} FROM tenants t WHERE ($2::text IS NULL OR t.name = $2) - AND ($3::text IS NULL OR t.route = $3) + AND ($3::text IS NULL OR lower(t.alias) = lower($3)) AND ($4::text IS NULL OR t.status = $4) - AND ($5::text IS NULL OR t.name ILIKE $5 OR t.route ILIKE $5 OR array_to_string(t.tags, ',') ILIKE $5 OR t.attributes::text ILIKE $5) + AND ($5::text IS NULL OR t.name ILIKE $5 OR t.alias ILIKE $5 OR array_to_string(t.tags, ',') ILIKE $5 OR t.attributes::text ILIKE $5) AND EXISTS ( SELECT 1 FROM effective_access_edges() pb @@ -352,7 +353,7 @@ pub async fn list_tenants_for_entity( )) .bind(entity_id) .bind(name.clone()) - .bind(route.clone()) + .bind(alias.clone()) .bind(status.clone()) .bind(q.clone()) .bind(access_actions.as_slice()) @@ -365,9 +366,9 @@ pub async fn list_tenants_for_entity( let total: i64 = sqlx::query_scalar( r#"SELECT COUNT(*) FROM tenants t WHERE ($2::text IS NULL OR t.name = $2) - AND ($3::text IS NULL OR t.route = $3) + AND ($3::text IS NULL OR lower(t.alias) = lower($3)) AND ($4::text IS NULL OR t.status = $4) - AND ($5::text IS NULL OR t.name ILIKE $5 OR t.route ILIKE $5 OR array_to_string(t.tags, ',') ILIKE $5 OR t.attributes::text ILIKE $5) + AND ($5::text IS NULL OR t.name ILIKE $5 OR t.alias ILIKE $5 OR array_to_string(t.tags, ',') ILIKE $5 OR t.attributes::text ILIKE $5) AND EXISTS ( SELECT 1 FROM effective_access_edges() pb @@ -425,7 +426,7 @@ pub async fn list_tenants_for_entity( ) .bind(entity_id) .bind(name) - .bind(route) + .bind(alias) .bind(status) .bind(q) .bind(access_actions.as_slice()) @@ -442,10 +443,11 @@ pub async fn update_tenant( req: UpdateTenant, updated_by: Option, ) -> Result { + let alias = crate::models::alias::validate_alias_opt(req.alias)?; sqlx::query_as::<_, Tenant>(&format!( r#"UPDATE tenants SET name = COALESCE($2, name), - route = COALESCE($3, route), + alias = COALESCE($3, alias), tags = COALESCE($4, tags), attributes = COALESCE($5, attributes), updated_by = $6, @@ -455,7 +457,7 @@ pub async fn update_tenant( )) .bind(id) .bind(req.name) - .bind(req.route) + .bind(alias) .bind(req.tags) .bind(req.attributes) .bind(updated_by) @@ -673,7 +675,7 @@ pub async fn list_tenant_members( let q = search_pattern(q); let items = sqlx::query_as::<_, Entity>( - r#"SELECT e.id, e.kind, e.name, e.tenant_id, e.profile_id, e.profile_version_id, + r#"SELECT e.id, e.kind, e.name, e.alias, e.tenant_id, e.profile_id, e.profile_version_id, e.status, e.attributes, e.created_at, e.updated_at FROM tenant_memberships tm JOIN entities e ON e.id = tm.entity_id @@ -722,7 +724,7 @@ pub async fn list_tenant_assignable_entities( let q = search_pattern(Some(q)); let items = sqlx::query_as::<_, Entity>( - r#"SELECT e.id, e.kind, e.name, e.tenant_id, e.profile_id, e.profile_version_id, + r#"SELECT e.id, e.kind, e.name, e.alias, e.tenant_id, e.profile_id, e.profile_version_id, e.status, e.attributes, e.created_at, e.updated_at FROM entities e WHERE e.kind = 'human' @@ -1227,7 +1229,7 @@ mod tests { let req = CreateTenant { id: None, name: unique_name("acme"), - route: Some(unique_name("acme-route")), + alias: Some(unique_name("acme-alias")), tags: vec!["pilot".into()], attributes: json!({"region": "eu"}), }; @@ -1248,7 +1250,7 @@ mod tests { CreateTenant { id: None, name: unique_name("list-a"), - route: None, + alias: None, tags: vec![], attributes: Value::Null, }, @@ -1261,7 +1263,7 @@ mod tests { CreateTenant { id: None, name: unique_name("list-b"), - route: None, + alias: None, tags: vec![], attributes: Value::Null, }, @@ -1278,7 +1280,7 @@ mod tests { ListTenants { q: None, name: None, - route: None, + alias: None, status: Some(TenantStatus::Active), limit: 100, offset: 0, @@ -1300,7 +1302,7 @@ mod tests { CreateTenant { id: None, name: unique_name("upd"), - route: Some("orig-route".into()), + alias: Some("orig-alias".into()), tags: vec!["x".into()], attributes: json!({"k": "v"}), }, @@ -1313,7 +1315,7 @@ mod tests { t.id, UpdateTenant { name: Some("renamed".into()), - route: None, + alias: None, tags: None, attributes: None, }, @@ -1322,7 +1324,7 @@ mod tests { .await .expect("update"); assert_eq!(upd.name, "renamed"); - assert_eq!(upd.route.as_deref(), Some("orig-route")); + assert_eq!(upd.alias.as_deref(), Some("orig-alias")); assert_eq!(upd.tags, vec!["x".to_string()]); cleanup(&pool, &[t.id]).await; } @@ -1336,7 +1338,7 @@ mod tests { CreateTenant { id: None, name: unique_name("status"), - route: None, + alias: None, tags: vec![], attributes: Value::Null, }, diff --git a/tests/m11_graphql_primitives.rs b/tests/m11_graphql_primitives.rs index 11044fa..faabb56 100644 --- a/tests/m11_graphql_primitives.rs +++ b/tests/m11_graphql_primitives.rs @@ -112,7 +112,7 @@ async fn profile_with_schema(pool: &PgPool, json_schema: Value) -> Uuid { } async fn delete_tenant_row(pool: &PgPool, tenant_id: Uuid) { - let _ = sqlx::query_as::<_, Tenant>("DELETE FROM tenants WHERE id = $1 RETURNING id, name, route, status, tags, attributes, created_by, updated_by, created_at, updated_at") + let _ = sqlx::query_as::<_, Tenant>("DELETE FROM tenants WHERE id = $1 RETURNING id, name, alias, status, tags, attributes, created_by, updated_by, created_at, updated_at") .bind(tenant_id) .fetch_optional(pool) .await; @@ -232,7 +232,7 @@ async fn create_list_and_get_tenant() { let pool = common::pool().await; let schema = build_schema(state(pool.clone()).await); let name = format!("graphql-tenant-{}", Uuid::new_v4()); - let route = format!("graphql-route-{}", Uuid::new_v4()); + let alias = format!("graphql-alias-{}", Uuid::new_v4()); let created = schema .execute(authed(format!( @@ -240,13 +240,13 @@ async fn create_list_and_get_tenant() { mutation {{ createTenant(input: {{ name: "{name}", - route: "{route}", + alias: "{alias}", tags: ["graphql"], attributes: {{ source: "graphql" }} }}) {{ id name - route + alias status tags attributes @@ -266,7 +266,7 @@ async fn create_list_and_get_tenant() { r#" {{ tenants(name: "{name}") {{ - items {{ id name route }} + items {{ id name alias }} total }} }} @@ -288,7 +288,7 @@ async fn create_list_and_get_tenant() { tenant(id: "{tenant_id}") {{ id name - route + alias }} }} "# diff --git a/tests/m18_aliases.rs b/tests/m18_aliases.rs new file mode 100644 index 0000000..c8f6f57 --- /dev/null +++ b/tests/m18_aliases.rs @@ -0,0 +1,173 @@ +//! Alias (human-friendly handle) integration tests. +//! +//! Covers scoped uniqueness (unique per tenant, reusable across tenants), +//! case-folding, slug/UUID-shape validation, and the two-level alias resolver. +//! All `#[ignore]`; run with: +//! +//! ```bash +//! DATABASE_URL=postgres://... cargo test --test m18_aliases -- --ignored +//! ``` + +mod common; + +use common::pool; + +use atom::authz::repo as authz_repo; +use atom::models::alias::AliasObjectClass; +use atom::models::resource::CreateResource; +use atom::models::tenant::CreateTenant; +use atom::tenants::repo as tenant_repo; +use serde_json::json; +use uuid::Uuid; + +/// A short, valid, unique slug for a test (aliases must be `[a-z0-9][a-z0-9-]*`). +fn slug(prefix: &str) -> String { + let id = Uuid::new_v4().simple().to_string(); + format!("{prefix}-{}", &id[..12]) +} + +async fn make_tenant(pool: &sqlx::PgPool, alias: &str) -> Uuid { + tenant_repo::create_tenant( + pool, + CreateTenant { + id: None, + name: slug("tenant"), + alias: Some(alias.to_string()), + tags: vec![], + attributes: json!({}), + }, + None, + ) + .await + .expect("create tenant") + .id +} + +fn resource_req(tenant_id: Uuid, alias: &str) -> CreateResource { + CreateResource { + id: None, + kind: "resource:channel".to_string(), + name: Some("chan".to_string()), + alias: Some(alias.to_string()), + tenant_id: Some(tenant_id), + owner_id: None, + attributes: json!({}), + } +} + +#[tokio::test] +#[ignore] +async fn alias_unique_within_tenant_but_reusable_across_tenants() { + let p = pool().await; + let tenant_a = make_tenant(&p, &slug("a")).await; + let tenant_b = make_tenant(&p, &slug("b")).await; + let alias = slug("chan"); + + authz_repo::create_resource(&p, resource_req(tenant_a, &alias)) + .await + .expect("first resource in tenant A"); + + // Same alias in a different tenant is allowed. + authz_repo::create_resource(&p, resource_req(tenant_b, &alias)) + .await + .expect("same alias reusable across tenants"); + + // Same alias again within tenant A is rejected (scoped uniqueness). + let dup = authz_repo::create_resource(&p, resource_req(tenant_a, &alias)).await; + assert!( + dup.is_err(), + "duplicate alias within a tenant must be rejected" + ); +} + +#[tokio::test] +#[ignore] +async fn resolve_alias_resolves_tenant_and_object() { + let p = pool().await; + let tenant_alias = slug("dom"); + let tenant_id = make_tenant(&p, &tenant_alias).await; + let object_alias = slug("meter"); + let resource = authz_repo::create_resource(&p, resource_req(tenant_id, &object_alias)) + .await + .expect("create resource"); + + let resolved = authz_repo::resolve_alias( + &p, + None, + Some(&tenant_alias), + AliasObjectClass::Resource, + &object_alias, + ) + .await + .expect("resolve by tenant alias + object alias"); + assert_eq!(resolved.tenant_id, tenant_id); + assert_eq!(resolved.object_id, resource.id); + + // Unknown object alias → NotFound. + let miss = authz_repo::resolve_alias( + &p, + Some(tenant_id), + None, + AliasObjectClass::Resource, + "does-not-exist", + ) + .await; + assert!(miss.is_err(), "unknown alias must not resolve"); +} + +#[tokio::test] +#[ignore] +async fn resolve_alias_is_case_insensitive() { + let p = pool().await; + let tenant_alias = slug("dom"); + let tenant_id = make_tenant(&p, &tenant_alias).await; + // Stored lowercased on write; resolve with mixed case must still match. + let resource = authz_repo::create_resource(&p, resource_req(tenant_id, "watermeters")) + .await + .expect("create resource"); + + let resolved = authz_repo::resolve_alias( + &p, + Some(tenant_id), + None, + AliasObjectClass::Resource, + "WaterMeters", + ) + .await + .expect("case-insensitive resolve"); + assert_eq!(resolved.object_id, resource.id); +} + +#[tokio::test] +#[ignore] +async fn create_resource_rejects_invalid_aliases() { + let p = pool().await; + let tenant_id = make_tenant(&p, &slug("dom")).await; + + assert!( + authz_repo::create_resource(&p, resource_req(tenant_id, "has space")) + .await + .is_err(), + "non-slug alias must be rejected" + ); + assert!( + authz_repo::create_resource( + &p, + resource_req(tenant_id, "465358f9-07f4-4ea0-8cbb-2abc654442bd"), + ) + .await + .is_err(), + "UUID-shaped alias must be rejected" + ); +} + +#[tokio::test] +#[ignore] +async fn resource_alias_is_stored_case_folded() { + let p = pool().await; + let tenant_id = make_tenant(&p, &slug("dom")).await; + let created = authz_repo::create_resource(&p, resource_req(tenant_id, "Sensor-01")) + .await + .expect("create resource with mixed-case alias"); + assert_eq!(created.alias.as_deref(), Some("sensor-01")); +} diff --git a/tests/m3_lifecycle.rs b/tests/m3_lifecycle.rs index c6e4a98..53c1dfa 100644 --- a/tests/m3_lifecycle.rs +++ b/tests/m3_lifecycle.rs @@ -25,7 +25,7 @@ async fn fresh_tenant(pool: &sqlx::PgPool) -> uuid::Uuid { CreateTenant { id: None, name: format!("m3-{}", Uuid::new_v4()), - route: None, + alias: None, tags: vec![], attributes: serde_json::Value::Null, }, diff --git a/tests/m4_inheritance.rs b/tests/m4_inheritance.rs index 695cf13..8330aaa 100644 --- a/tests/m4_inheritance.rs +++ b/tests/m4_inheritance.rs @@ -33,7 +33,7 @@ async fn tenant(pool: &sqlx::PgPool) -> Uuid { CreateTenant { id: None, name: format!("m4-{}", Uuid::new_v4()), - route: None, + alias: None, tags: vec![], attributes: serde_json::Value::Null, }, diff --git a/tests/m5_bootstrap.rs b/tests/m5_bootstrap.rs index 5fcb391..8ed82b3 100644 --- a/tests/m5_bootstrap.rs +++ b/tests/m5_bootstrap.rs @@ -42,7 +42,7 @@ async fn create_tenant(pool: &sqlx::PgPool, creator_id: Uuid) -> Uuid { CreateTenant { id: None, name: format!("m5-{}", Uuid::new_v4()), - route: None, + alias: None, tags: vec![], attributes: serde_json::Value::Null, }, diff --git a/tests/m6_conditions.rs b/tests/m6_conditions.rs index 0fa6bbe..9256224 100644 --- a/tests/m6_conditions.rs +++ b/tests/m6_conditions.rs @@ -30,7 +30,7 @@ async fn tenant(pool: &sqlx::PgPool) -> Uuid { CreateTenant { id: None, name: format!("m6-{}", Uuid::new_v4()), - route: None, + alias: None, tags: vec![], attributes: json!({"tier": "gold"}), }, diff --git a/tests/m7_audit.rs b/tests/m7_audit.rs index 3efe593..5d1c0e7 100644 --- a/tests/m7_audit.rs +++ b/tests/m7_audit.rs @@ -30,7 +30,7 @@ async fn tenant(pool: &sqlx::PgPool) -> Uuid { CreateTenant { id: None, name: format!("m7-{}", Uuid::new_v4()), - route: None, + alias: None, tags: vec![], attributes: serde_json::Value::Null, }, diff --git a/tests/m9_profiles.rs b/tests/m9_profiles.rs index d5c0c70..3c7a023 100644 --- a/tests/m9_profiles.rs +++ b/tests/m9_profiles.rs @@ -68,6 +68,7 @@ fn entity_request(name: String) -> CreateEntity { profile_id: None, profile_version_id: None, name, + alias: None, tenant_id: None, attributes: json!({}), } From d5813cfcfa1b28210b12e9ce54f2ce45187ff3d6 Mon Sep 17 00:00:00 2001 From: dusan Date: Fri, 19 Jun 2026 12:57:29 +0200 Subject: [PATCH 2/3] Add support for aliases Signed-off-by: dusan --- README.md | 6 +- apidocs/grpc-reference.md | 7 +- apidocs/grpc.md | 56 +++++- apidocs/openapi.yaml | 28 ++- .../crud/table/initial-values.test.ts | 34 ++++ app/components/crud/table/initial-values.ts | 2 + .../endpoints/api-endpoints-table.tsx | 44 +++-- .../entities/entity-create-form.tsx | 29 +++- .../resources/resource-create-form.test.tsx | 69 ++++++++ .../resources/resource-create-form.tsx | 49 +++++- app/lib/crud/resources.ts | 10 +- demo/atom-httpie-demo.sh | 2 +- docs/content/docs/architecture/data-model.mdx | 2 +- docs/content/docs/endpoints.mdx | 16 +- docs/content/docs/index.mdx | 4 + docs/content/docs/quickstart.mdx | 2 + migrations/005_alias_uuid_guardrails.sql | 30 ++++ postman/Atom.postman_collection.json | 6 +- product-docs/10-magistrala-on-atom.md | 4 +- product-docs/PRD.md | 2 +- proto/atom/v1/atom.proto | 12 +- src/authz/repo.rs | 53 +++--- src/graphql/entities.rs | 2 +- src/graphql/resources.rs | 2 +- src/graphql/tenants.rs | 2 +- src/graphql/types/mod.rs | 8 +- src/grpc.rs | 38 ++++- src/identity/repo.rs | 7 +- src/models/alias.rs | 53 ++++++ src/models/entity.rs | 6 +- src/models/resource.rs | 6 +- src/models/tenant.rs | 6 +- src/tenants/repo.rs | 13 +- tests/m11_graphql_primitives.rs | 18 ++ tests/m18_aliases.rs | 161 +++++++++++++++++- 35 files changed, 684 insertions(+), 105 deletions(-) create mode 100644 app/components/crud/table/initial-values.test.ts create mode 100644 app/components/resources/resource-create-form.test.tsx create mode 100644 migrations/005_alias_uuid_guardrails.sql diff --git a/README.md b/README.md index 80806fd..082abd5 100644 --- a/README.md +++ b/README.md @@ -395,12 +395,14 @@ mutation { createEntity(input: { profileId: "client-profile-id", name: "meter-001", + alias: "meter-001", attributes: { serial_no: "WM-001" } }) { id kind + alias profileId profileVersionId attributes @@ -411,6 +413,7 @@ mutation { createResource(input: { kind: "channel", name: "telemetry", + alias: "telemetry", attributes: { topic: "telemetry" } @@ -418,6 +421,7 @@ mutation { id kind name + alias attributes } } @@ -792,7 +796,7 @@ active | inactive | frozen | deleted | ---------------- | -------------------- | | domain `id` | `tenants.id` | | domain `name` | `tenants.name` | -| `route` | `tenants.route` | +| domain `route` | `tenants.alias` | | `metadata` | `tenants.attributes` | | `tags` | `tenants.tags` | | `enabled` | `status = active` | diff --git a/apidocs/grpc-reference.md b/apidocs/grpc-reference.md index 36b6bbe..78b6b88 100644 --- a/apidocs/grpc-reference.md +++ b/apidocs/grpc-reference.md @@ -124,10 +124,11 @@ | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| tenant_id | [string](#string) | | Tenant selector — exactly one of these identifies the domain. tenant_id wins if both are set; tenant_alias is the case-folded tenant slug. | +| tenant_id | [string](#string) | | Tenant selector — exactly one of tenant_id, tenant_alias, or global must be set. tenant_alias is the case-folded tenant slug. | | tenant_alias | [string](#string) | | | -| object_kind | [string](#string) | | Which table the object alias addresses: "entity" (clients/devices) or "resource" (channels). Anything other than "entity" is treated as a resource. Generic on purpose — no domain/channel vocabulary. | +| object_kind | [string](#string) | | Which table the object alias addresses: "entity" (clients/devices) or "resource" (channels). Other values are rejected. Generic on purpose — no domain/channel vocabulary. | | object_alias | [string](#string) | | The object's alias slug, unique within the tenant. | +| global | [bool](#bool) | | Resolve an entity or resource whose tenant_id is NULL. | @@ -142,7 +143,7 @@ | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| tenant_id | [string](#string) | | | +| tenant_id | [string](#string) | | empty string for global objects | | object_id | [string](#string) | | | diff --git a/apidocs/grpc.md b/apidocs/grpc.md index d61bf49..a5c7600 100644 --- a/apidocs/grpc.md +++ b/apidocs/grpc.md @@ -1,6 +1,6 @@ # gRPC API Reference -Atom exposes three gRPC services on port **8081** by default, configurable with `GRPC_ADDR`. +Atom exposes four gRPC services on port **8081** by default, configurable with `GRPC_ADDR`. The proto source lives at [`proto/atom/v1/atom.proto`](../proto/atom/v1/atom.proto). The generated proto reference lives at [`apidocs/grpc-reference.md`](./grpc-reference.md) and should be regenerated only when the proto changes. @@ -18,7 +18,7 @@ Runtime services should call Atom over the service network. The default containe ### Authentication Metadata -`AuthzService.Check`, `CertificateService.ResolveCertificate`, and `CertificateService.RevokeEntityCertificates` require gRPC metadata: +`AuthzService.Check`, `AliasService.ResolveAlias`, `CertificateService.ResolveCertificate`, and `CertificateService.RevokeEntityCertificates` require gRPC metadata: ```text authorization: Bearer @@ -149,6 +149,58 @@ grpcurl -plaintext \ --- +### `atom.v1.AliasService` + +Alias resolution converts human-friendly tenant/entity/resource handles into canonical UUIDs. Resolution does not grant access; callers must authorize the returned object UUID separately with `AuthzService.Check`. + +#### `ResolveAlias` + +```text +rpc ResolveAlias(ResolveAliasRequest) returns (ResolveAliasResponse) +``` + +Requires `authorization: Bearer ` metadata. + +Exactly one tenant selector is required: + +- `tenant_id` for a tenant UUID; +- `tenant_alias` for a case-insensitive tenant alias; +- `global = true` for an entity or resource whose `tenant_id` is null. + +`object_kind` must be exactly `entity` or `resource` (case-insensitive). Other values return `INVALID_ARGUMENT`. + +**Request: `ResolveAliasRequest`** + +| Field | Type | Required | Description | +|---|---|---|---| +| `tenant_id` | `string` UUID | conditional | Tenant UUID selector. | +| `tenant_alias` | `string` | conditional | Tenant alias selector. | +| `global` | `bool` | conditional | Select the global null-tenant namespace. | +| `object_kind` | `string` | yes | `entity` or `resource`. | +| `object_alias` | `string` | yes | Object alias within the selected namespace. | + +**Response: `ResolveAliasResponse`** + +| Field | Type | Description | +|---|---|---| +| `tenant_id` | `string` UUID | Resolved tenant; empty for global objects. | +| `object_id` | `string` UUID | Resolved entity or resource UUID. | + +**Example** + +```bash +grpcurl -plaintext \ + -H 'authorization: Bearer '"$ATOM_TOKEN" \ + -d '{ + "tenant_alias": "factory-a", + "object_kind": "resource", + "object_alias": "telemetry" + }' \ + atom:8081 atom.v1.AliasService/ResolveAlias +``` + +--- + ### `atom.v1.CertificateService` Certificate runtime lookup and entity-wide certificate revocation for services that terminate mTLS outside Atom. diff --git a/apidocs/openapi.yaml b/apidocs/openapi.yaml index 5814183..bbe13c7 100644 --- a/apidocs/openapi.yaml +++ b/apidocs/openapi.yaml @@ -205,6 +205,9 @@ components: $ref: '#/components/schemas/EntityKind' name: type: string + alias: + type: string + nullable: true tenant_id: type: string format: uuid @@ -252,6 +255,8 @@ components: name: type: string example: sensor-01 + alias: + type: string tenant_id: type: string format: uuid @@ -263,6 +268,9 @@ components: properties: name: type: string + alias: + type: string + nullable: true status: $ref: '#/components/schemas/EntityStatus' attributes: @@ -597,6 +605,9 @@ components: name: type: string nullable: true + alias: + type: string + nullable: true tenant_id: type: string format: uuid @@ -625,6 +636,8 @@ components: example: channel name: type: string + alias: + type: string tenant_id: type: string format: uuid @@ -639,6 +652,9 @@ components: properties: name: type: string + alias: + type: string + nullable: true attributes: type: object @@ -899,7 +915,7 @@ components: format: uuid name: type: string - route: + alias: type: string nullable: true status: @@ -933,8 +949,9 @@ components: name: type: string example: factory-1 - route: + alias: type: string + nullable: true tags: type: array items: @@ -947,8 +964,9 @@ components: properties: name: type: string - route: + alias: type: string + nullable: true tags: type: array items: @@ -2384,7 +2402,7 @@ paths: in: query schema: type: string - - name: route + - name: alias in: query schema: type: string @@ -2425,7 +2443,7 @@ paths: $ref: '#/components/schemas/CreateTenant' example: name: factory-1 - route: factory-1 + alias: factory-1 tags: [pilot] attributes: magistrala: diff --git a/app/components/crud/table/initial-values.test.ts b/app/components/crud/table/initial-values.test.ts new file mode 100644 index 0000000..e4a4e46 --- /dev/null +++ b/app/components/crud/table/initial-values.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + entityFormInitialValues, + resourceFormInitialValues, +} from "@/components/crud/table/initial-values"; + +describe("alias form initial values", () => { + it("loads entity aliases for editing", () => { + const values = entityFormInitialValues({ + id: "entity-1", + name: "Sensor", + alias: "sensor-01", + kind: "device", + }); + + expect(values.alias).toBe("sensor-01"); + }); + + it("loads resource aliases for editing", () => { + const values = resourceFormInitialValues({ + id: "resource-1", + kind: "resource:channel", + name: "Telemetry", + alias: "telemetry", + }); + + expect(values.alias).toBe("telemetry"); + }); + + it("uses an empty alias when the row has none", () => { + expect(entityFormInitialValues({ id: "entity-1" }).alias).toBe(""); + expect(resourceFormInitialValues({ id: "resource-1" }).alias).toBe(""); + }); +}); diff --git a/app/components/crud/table/initial-values.ts b/app/components/crud/table/initial-values.ts index 9111de5..390f2dd 100644 --- a/app/components/crud/table/initial-values.ts +++ b/app/components/crud/table/initial-values.ts @@ -43,6 +43,7 @@ export function resourceFormInitialValues(row: Row): ResourceFormInitialValues { id: String(row.id), kind: typeof row.kind === "string" ? row.kind : "", name: typeof row.name === "string" ? row.name : "", + alias: typeof row.alias === "string" ? row.alias : "", tenantId: typeof row.tenantId === "string" ? row.tenantId : "", ownerId: typeof row.ownerId === "string" ? row.ownerId : "", attributes: @@ -87,6 +88,7 @@ export function entityFormInitialValues(row: Row): EntityFormInitialValues { return { id: String(row.id), name: typeof row.name === "string" ? row.name : "", + alias: typeof row.alias === "string" ? row.alias : "", kind: (ENTITY_KINDS as readonly string[]).includes(rawKind) ? (rawKind as EntityFormInitialValues["kind"]) : "human", diff --git a/app/components/endpoints/api-endpoints-table.tsx b/app/components/endpoints/api-endpoints-table.tsx index 9d1343b..97ab7af 100644 --- a/app/components/endpoints/api-endpoints-table.tsx +++ b/app/components/endpoints/api-endpoints-table.tsx @@ -245,15 +245,17 @@ const ENDPOINT_PRESETS: EndpointPreset[] = [ values: { key: "create_entity", name: "Create Entity", - description: "Creates an entity with a name, kind, and optional tenant.", + description: + "Creates an entity with a name, kind, optional alias, and optional tenant.", method: "POST", path: "/api/custom/entities", operationKind: "mutation", - graphql: `mutation CreateEntity($input: CreateEntityInput!) {\n createEntity(input: $input) {\n id\n name\n kind\n status\n createdAt\n }\n}`, + graphql: `mutation CreateEntity($input: CreateEntityInput!) {\n createEntity(input: $input) {\n id\n name\n alias\n kind\n status\n createdAt\n }\n}`, authMode: "caller_context", variablesMapping: JSON.stringify( { "input.name": "$body.name", + "input.alias": "$body.alias", "input.kind": "$body.kind", "input.tenantId": "$body.tenantId", }, @@ -266,6 +268,7 @@ const ENDPOINT_PRESETS: EndpointPreset[] = [ required: ["name"], properties: { name: { type: "string" }, + alias: { type: "string" }, kind: { type: "string" }, tenantId: { type: "string" }, }, @@ -283,16 +286,17 @@ const ENDPOINT_PRESETS: EndpointPreset[] = [ values: { key: "update_entity", name: "Update Entity", - description: "Updates an entity's name or attributes.", + description: "Updates an entity's name, alias, or attributes.", method: "PATCH", path: "/api/custom/entities/update", operationKind: "mutation", - graphql: `mutation UpdateEntity($id: ID!, $input: UpdateEntityInput!) {\n updateEntity(id: $id, input: $input) {\n id\n name\n kind\n status\n updatedAt\n }\n}`, + graphql: `mutation UpdateEntity($id: ID!, $input: UpdateEntityInput!) {\n updateEntity(id: $id, input: $input) {\n id\n name\n alias\n kind\n status\n updatedAt\n }\n}`, authMode: "caller_context", variablesMapping: JSON.stringify( { id: "$query.id", "input.name": "$body.name", + "input.alias": "$body.alias", "input.attributes": "$body.attributes", }, null, @@ -303,6 +307,7 @@ const ENDPOINT_PRESETS: EndpointPreset[] = [ type: "object", properties: { name: { type: "string" }, + alias: { type: ["string", "null"] }, attributes: { type: "object" }, }, }, @@ -319,14 +324,20 @@ const ENDPOINT_PRESETS: EndpointPreset[] = [ values: { key: "create_resource", name: "Create Resource", - description: "Creates a resource that can be referenced in policies.", + description: + "Creates a resource with an optional alias that can be referenced in policies.", method: "POST", path: "/api/custom/resources", operationKind: "mutation", - graphql: `mutation CreateResource($input: CreateResourceInput!) {\n createResource(input: $input) {\n id\n name\n kind\n status\n createdAt\n }\n}`, + graphql: `mutation CreateResource($input: CreateResourceInput!) {\n createResource(input: $input) {\n id\n name\n alias\n kind\n createdAt\n }\n}`, authMode: "caller_context", variablesMapping: JSON.stringify( - { "input.name": "$body.name", "input.kind": "$body.kind" }, + { + "input.name": "$body.name", + "input.alias": "$body.alias", + "input.kind": "$body.kind", + "input.tenantId": "$body.tenantId", + }, null, 2, ), @@ -336,7 +347,9 @@ const ENDPOINT_PRESETS: EndpointPreset[] = [ required: ["name"], properties: { name: { type: "string" }, + alias: { type: "string" }, kind: { type: "string" }, + tenantId: { type: "string" }, }, }, null, @@ -352,21 +365,30 @@ const ENDPOINT_PRESETS: EndpointPreset[] = [ values: { key: "update_resource", name: "Update Resource", - description: "Updates a resource's name or attributes.", + description: "Updates a resource's name, alias, or attributes.", method: "PATCH", path: "/api/custom/resources/update", operationKind: "mutation", - graphql: `mutation UpdateResource($id: ID!, $input: UpdateResourceInput!) {\n updateResource(id: $id, input: $input) {\n id\n name\n kind\n status\n updatedAt\n }\n}`, + graphql: `mutation UpdateResource($id: ID!, $input: UpdateResourceInput!) {\n updateResource(id: $id, input: $input) {\n id\n name\n alias\n kind\n updatedAt\n }\n}`, authMode: "caller_context", variablesMapping: JSON.stringify( - { id: "$query.id", "input.name": "$body.name" }, + { + id: "$query.id", + "input.name": "$body.name", + "input.alias": "$body.alias", + "input.attributes": "$body.attributes", + }, null, 2, ), requestSchema: JSON.stringify( { type: "object", - properties: { name: { type: "string" } }, + properties: { + name: { type: "string" }, + alias: { type: ["string", "null"] }, + attributes: { type: "object" }, + }, }, null, 2, diff --git a/app/components/entities/entity-create-form.tsx b/app/components/entities/entity-create-form.tsx index d5ac029..ddc8767 100644 --- a/app/components/entities/entity-create-form.tsx +++ b/app/components/entities/entity-create-form.tsx @@ -75,6 +75,7 @@ const CREATE_ENTITY_MUTATION = ` profileId profileVersionId name + alias tenantId status createdAt @@ -91,6 +92,7 @@ const UPDATE_ENTITY_MUTATION = ` profileId profileVersionId name + alias tenantId status updatedAt @@ -133,6 +135,7 @@ type ProfileVersionsData = { const entityFormSchema = z.object({ name: z.string().trim().min(1, "Name is required."), + alias: z.string().trim(), kind: z.enum(ENTITY_KINDS), tenantId: z.string().trim(), profileId: z.string().trim(), @@ -202,6 +205,7 @@ type EntityFormValues = z.infer; export type EntityFormInitialValues = { id: string; name: string; + alias: string; kind: (typeof ENTITY_KINDS)[number]; tenantId: string; profileId: string; @@ -211,6 +215,7 @@ export type EntityFormInitialValues = { const defaultValues: EntityFormValues = { name: "", + alias: "", kind: "human", tenantId: "", profileId: "", @@ -243,6 +248,7 @@ export function EntityCreateForm({ defaultValues: entity ? { name: entity.name, + alias: entity.alias, kind: entity.kind, tenantId: entity.tenantId, profileId: entity.profileId, @@ -340,14 +346,17 @@ export function EntityCreateForm({ query: UPDATE_ENTITY_MUTATION, variables: { id: entity.id, - input: removeEmptyValues({ - name: values.name, - kind: values.kind, - tenantId: values.tenantId, - profileId: values.profileId, - profileVersionId: values.profileVersionId, - attributes, - }), + input: { + ...removeEmptyValues({ + name: values.name, + kind: values.kind, + tenantId: values.tenantId, + profileId: values.profileId, + profileVersionId: values.profileVersionId, + attributes, + }), + alias: values.alias || null, + }, }, }); } else { @@ -356,6 +365,7 @@ export function EntityCreateForm({ variables: { input: removeEmptyValues({ name: values.name, + alias: values.alias, kind: values.kind, tenantId: values.tenantId, profileId: values.profileId, @@ -382,6 +392,7 @@ export function EntityCreateForm({ + @@ -424,7 +435,7 @@ function TextField({ }: { form: UseFormReturn; label: string; - name: "name"; + name: "name" | "alias"; required?: boolean; }) { return ( diff --git a/app/components/resources/resource-create-form.test.tsx b/app/components/resources/resource-create-form.test.tsx new file mode 100644 index 0000000..55dbc33 --- /dev/null +++ b/app/components/resources/resource-create-form.test.tsx @@ -0,0 +1,69 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ResourceCreateForm } from "@/components/resources/resource-create-form"; + +const mocks = vi.hoisted(() => ({ + graphqlClient: vi.fn(), +})); + +vi.mock("@/lib/graphql/client", () => ({ + graphqlClient: mocks.graphqlClient, +})); + +function renderForm() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + render( + + + , + ); +} + +describe("ResourceCreateForm aliases", () => { + afterEach(cleanup); + + beforeEach(() => { + mocks.graphqlClient.mockReset(); + mocks.graphqlClient.mockResolvedValue({}); + }); + + it("sends explicit null when an existing alias is cleared", async () => { + const user = userEvent.setup(); + renderForm(); + + const alias = screen.getByLabelText("Alias"); + await user.clear(alias); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + await waitFor(() => { + expect(mocks.graphqlClient).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + id: "resource-1", + input: expect.objectContaining({ alias: null }), + }, + }), + ); + }); + }); +}); diff --git a/app/components/resources/resource-create-form.tsx b/app/components/resources/resource-create-form.tsx index 81be596..35a4461 100644 --- a/app/components/resources/resource-create-form.tsx +++ b/app/components/resources/resource-create-form.tsx @@ -33,7 +33,7 @@ import { GLOBAL_TENANT } from "@/lib/tenant/context"; const CREATE_RESOURCE_MUTATION = ` mutation CreateResource($input: CreateResourceInput!) { createResource(input: $input) { - id kind name tenantId ownerId attributes createdAt updatedAt + id kind name alias tenantId ownerId attributes createdAt updatedAt } } `; @@ -41,7 +41,7 @@ const CREATE_RESOURCE_MUTATION = ` const UPDATE_RESOURCE_MUTATION = ` mutation UpdateResource($id: ID!, $input: UpdateResourceInput!) { updateResource(id: $id, input: $input) { - id kind name tenantId ownerId attributes createdAt updatedAt + id kind name alias tenantId ownerId attributes createdAt updatedAt } } `; @@ -82,6 +82,7 @@ const attributesSchema = z.string().superRefine((val, ctx) => { const createSchema = z.object({ kind: z.string().trim().min(1, "Kind is required."), name: z.string().trim(), + alias: z.string().trim(), tenantId: z.string(), ownerId: z.string(), attributes: attributesSchema, @@ -89,6 +90,7 @@ const createSchema = z.object({ const editSchema = z.object({ name: z.string().trim(), + alias: z.string().trim(), attributes: attributesSchema, }); @@ -101,6 +103,7 @@ export type ResourceFormInitialValues = { id: string; kind: string; name: string; + alias: string; tenantId: string; ownerId: string; attributes: unknown; @@ -140,6 +143,7 @@ function CreateForm({ defaultValues: { kind: "", name: "", + alias: "", tenantId: "", ownerId: "", attributes: "{}", @@ -154,6 +158,7 @@ function CreateForm({ input: { kind: values.kind, name: values.name || undefined, + alias: values.alias || undefined, tenantId: values.tenantId || undefined, ownerId: values.ownerId || undefined, attributes: parseAttributes(values.attributes), @@ -175,6 +180,7 @@ function CreateForm({ > + @@ -203,6 +209,7 @@ function EditForm({ resolver: zodResolver(editSchema), defaultValues: { name: resource.name, + alias: resource.alias, attributes: stringifyAttributes(resource.attributes), }, }); @@ -215,6 +222,7 @@ function EditForm({ id: resource.id, input: { name: values.name || undefined, + alias: values.alias || null, attributes: parseAttributes(values.attributes), }, }, @@ -236,6 +244,7 @@ function EditForm({ + }) { ); } +function AliasField({ form }: { form: UseFormReturn }) { + return ( + ( + + Alias + + + + + + )} + /> + ); +} + function EditNameField({ form }: { form: UseFormReturn }) { return ( }) { ); } +function EditAliasField({ form }: { form: UseFormReturn }) { + return ( + ( + + Alias + + + + + + )} + /> + ); +} + function TenantSelectField({ form, tenants, diff --git a/app/lib/crud/resources.ts b/app/lib/crud/resources.ts index bdf158d..c408609 100644 --- a/app/lib/crud/resources.ts +++ b/app/lib/crud/resources.ts @@ -80,11 +80,12 @@ export const crudResources: CrudResource[] = [ icon: Fingerprint, queryName: "entities", tenantFilter: true, - listQuery: `query Entities($tenantId: ID, $limit: Int = 50, $offset: Int = 0) { entities(tenantId: $tenantId, limit: $limit, offset: $offset) { total items { id kind profileId profileVersionId name tenantId parentGroupId attributes status createdAt updatedAt } } }`, - createMutation: `mutation CreateEntity($input: CreateEntityInput!) { createEntity(input: $input) { id kind profileId profileVersionId name tenantId status createdAt updatedAt } }`, + listQuery: `query Entities($tenantId: ID, $limit: Int = 50, $offset: Int = 0) { entities(tenantId: $tenantId, limit: $limit, offset: $offset) { total items { id kind profileId profileVersionId name alias tenantId parentGroupId attributes status createdAt updatedAt } } }`, + createMutation: `mutation CreateEntity($input: CreateEntityInput!) { createEntity(input: $input) { id kind profileId profileVersionId name alias tenantId status createdAt updatedAt } }`, formAttributes: true, columns: [ { key: "name", label: "Name", priority: "high" }, + { key: "alias", label: "Alias", priority: "medium" }, { key: "kind", label: "Kind", priority: "high" }, { key: "profileId", label: "Profile", priority: "medium" }, { key: "status", label: "Status", priority: "high" }, @@ -174,13 +175,14 @@ export const crudResources: CrudResource[] = [ icon: Server, queryName: "resources", tenantFilter: true, - listQuery: `query Resources($tenantId: ID, $limit: Int = 50, $offset: Int = 0) { resources(tenantId: $tenantId, limit: $limit, offset: $offset) { total items { id kind name tenantId ownerId parentGroupId attributes createdAt updatedAt } } }`, - createMutation: `mutation CreateResource($input: CreateResourceInput!) { createResource(input: $input) { id kind name tenantId ownerId createdAt updatedAt } }`, + listQuery: `query Resources($tenantId: ID, $limit: Int = 50, $offset: Int = 0) { resources(tenantId: $tenantId, limit: $limit, offset: $offset) { total items { id kind name alias tenantId ownerId parentGroupId attributes createdAt updatedAt } } }`, + createMutation: `mutation CreateResource($input: CreateResourceInput!) { createResource(input: $input) { id kind name alias tenantId ownerId createdAt updatedAt } }`, deleteMutation: `mutation DeleteResource($id: ID!) { deleteResource(id: $id) }`, deleteIdField: "id", formAttributes: true, columns: [ { key: "name", label: "Name", priority: "high" }, + { key: "alias", label: "Alias", priority: "medium" }, { key: "kind", label: "Kind", priority: "high" }, { key: "tenantId", label: "Tenant", priority: "medium" }, { key: "ownerId", label: "Owner", priority: "low" }, diff --git a/demo/atom-httpie-demo.sh b/demo/atom-httpie-demo.sh index 47eac41..856ff1f 100755 --- a/demo/atom-httpie-demo.sh +++ b/demo/atom-httpie-demo.sh @@ -52,7 +52,7 @@ PUBLISH_CAP="$(jq -r '.items[] | select(.name=="publish" and .resource_kind==nul call "Create tenant: factory-$RUN_ID" POST "$BASE_URL/tenants" "$AUTH" \ name="factory-$RUN_ID" \ - route="factory-$RUN_ID" \ + alias="factory-$RUN_ID" \ tags:='["demo","factory"]' \ attributes:='{"region":"demo","plan":"gold"}' TENANT_ID="$(jq -r '.id' "$tmp/last.json")" diff --git a/docs/content/docs/architecture/data-model.mdx b/docs/content/docs/architecture/data-model.mdx index b255a03..9e0e72a 100644 --- a/docs/content/docs/architecture/data-model.mdx +++ b/docs/content/docs/architecture/data-model.mdx @@ -77,7 +77,7 @@ Every `tenants`, `entities`, and `resources` row may carry an optional `alias`: - **Slug shape.** An alias is a lowercase slug of 1–63 characters using `a–z`, `0–9`, and `-`, with no leading or trailing dash. It is case-folded on write, may not be UUID-shaped, and is validated both in the service and by a database `CHECK`. - **Uniqueness.** Tenant aliases are unique across the platform. Entity and resource aliases are unique **within their tenant**, so the same alias (for example `watermeters`) may be reused in different tenants. -- **Resolution.** `AliasService.ResolveAlias` (gRPC) resolves a tenant alias plus an object alias to the underlying UUIDs in one call, so a caller can address objects by name and then authorize by UUID. Resolution is capability-neutral; the authorization gate is the subsequent `authz` check by UUID. +- **Resolution.** `AliasService.ResolveAlias` (gRPC) resolves a tenant alias plus an object alias to the underlying UUIDs in one call. Global entities and resources use the request's explicit `global` selector. Resolution is capability-neutral; the authorization gate is the subsequent `authz` check by UUID. Aliases stay an alias, not a replacement: rename one and every UUID-keyed grant, session, and audit row is unaffected. diff --git a/docs/content/docs/endpoints.mdx b/docs/content/docs/endpoints.mdx index 0ec5a00..cd0e9ee 100644 --- a/docs/content/docs/endpoints.mdx +++ b/docs/content/docs/endpoints.mdx @@ -136,7 +136,7 @@ Keys use dot notation to build nested GraphQL variable objects: ```json { "input.name": "$body.name", - "input.route": "$body.route", + "input.alias": "$body.alias", "input.tags": "$body.tags" } ``` @@ -147,7 +147,7 @@ This produces the GraphQL variables: { "input": { "name": "", - "route": "", + "alias": "", "tags": "" } } @@ -202,7 +202,7 @@ Validation failures return **400 Bad Request** with a message listing the schema "required": ["name"], "properties": { "name": { "type": "string" }, - "route": { "type": "string" }, + "alias": { "type": "string" }, "tenantId": { "type": "string" } } } @@ -390,7 +390,7 @@ Sensitive keys (`password`, `secret`, `token`, `authorization`, `apikey`, `api_k ```graphql mutation CreateTenant($input: CreateTenantInput!) { createTenant(input: $input) { - id name route status createdAt + id name alias status createdAt } } ``` @@ -398,7 +398,7 @@ mutation CreateTenant($input: CreateTenantInput!) { ```json title="variablesMapping" { "input.name": "$body.name", - "input.route": "$body.route" + "input.alias": "$body.alias" } ``` @@ -408,7 +408,7 @@ mutation CreateTenant($input: CreateTenantInput!) { "required": ["name"], "properties": { "name": { "type": "string" }, - "route": { "type": "string" } + "alias": { "type": "string" } } } ``` @@ -419,7 +419,7 @@ POST /api/custom/tenants Authorization: Bearer Content-Type: application/json -{ "name": "Acme", "route": "acme" } +{ "name": "Acme", "alias": "acme" } ``` **Response:** @@ -428,7 +428,7 @@ Content-Type: application/json "createTenant": { "id": "3334383c-e62c-4452-a88b-cd099942e7d2", "name": "Acme", - "route": "acme", + "alias": "acme", "status": "active", "createdAt": "2026-05-18T09:36:07.015819+00:00" } diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index fa2d783..7be9716 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -142,12 +142,14 @@ mutation { createEntity(input: { profileId: "client-profile-id", name: "meter-001", + alias: "meter-001", attributes: { serial_no: "WM-001" } }) { id kind + alias profileId profileVersionId attributes @@ -158,6 +160,7 @@ mutation { createResource(input: { kind: "channel", name: "telemetry", + alias: "telemetry", attributes: { topic: "telemetry" } @@ -165,6 +168,7 @@ mutation { id kind name + alias attributes } } diff --git a/docs/content/docs/quickstart.mdx b/docs/content/docs/quickstart.mdx index 047d915..f1ae29b 100644 --- a/docs/content/docs/quickstart.mdx +++ b/docs/content/docs/quickstart.mdx @@ -90,6 +90,7 @@ mutation { createEntity(input: { kind: device name: "meter-001" + alias: "meter-001" }) { id name @@ -105,6 +106,7 @@ mutation { createResource(input: { kind: "channel" name: "telemetry" + alias: "telemetry" }) { id kind diff --git a/migrations/005_alias_uuid_guardrails.sql b/migrations/005_alias_uuid_guardrails.sql new file mode 100644 index 0000000..a2e05c8 --- /dev/null +++ b/migrations/005_alias_uuid_guardrails.sql @@ -0,0 +1,30 @@ +-- Keep database enforcement aligned with the application alias validator. +-- Aliases use the same character set as UUID text, so the slug CHECK alone +-- would otherwise allow canonical or compact UUID strings. + +ALTER TABLE tenants + ADD CONSTRAINT chk_tenants_alias_not_uuid + CHECK ( + alias IS NULL OR alias !~ ( + '^([0-9a-f]{32}|' + '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' + ) + ); + +ALTER TABLE entities + ADD CONSTRAINT chk_entities_alias_not_uuid + CHECK ( + alias IS NULL OR alias !~ ( + '^([0-9a-f]{32}|' + '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' + ) + ); + +ALTER TABLE resources + ADD CONSTRAINT chk_resources_alias_not_uuid + CHECK ( + alias IS NULL OR alias !~ ( + '^([0-9a-f]{32}|' + '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$' + ) + ); diff --git a/postman/Atom.postman_collection.json b/postman/Atom.postman_collection.json index 21dc6c4..f162f5c 100644 --- a/postman/Atom.postman_collection.json +++ b/postman/Atom.postman_collection.json @@ -3302,7 +3302,7 @@ "method": "GET", "header": [], "url": { - "raw": "{{base_url}}/tenants?name=&route=&status=active&limit=20&offset=0", + "raw": "{{base_url}}/tenants?name=&alias=&status=active&limit=20&offset=0", "host": [ "{{base_url}}" ], @@ -3355,7 +3355,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"name\": \"tenant-{{run_id}}\",\n \"route\": \"tenant-{{run_id}}\",\n \"tags\": [\n \"postman\"\n ],\n \"attributes\": {\n \"source\": \"postman\"\n }\n}", + "raw": "{\n \"name\": \"tenant-{{run_id}}\",\n \"alias\": \"tenant-{{run_id}}\",\n \"tags\": [\n \"postman\"\n ],\n \"attributes\": {\n \"source\": \"postman\"\n }\n}", "options": { "raw": { "language": "json" @@ -3403,7 +3403,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"name\": \"tenant-updated-{{run_id}}\",\n \"route\": \"tenant-updated-{{run_id}}\",\n \"tags\": [\n \"postman\",\n \"updated\"\n ],\n \"attributes\": {\n \"updated_by\": \"postman\"\n }\n}", + "raw": "{\n \"name\": \"tenant-updated-{{run_id}}\",\n \"alias\": \"tenant-updated-{{run_id}}\",\n \"tags\": [\n \"postman\",\n \"updated\"\n ],\n \"attributes\": {\n \"updated_by\": \"postman\"\n }\n}", "options": { "raw": { "language": "json" diff --git a/product-docs/10-magistrala-on-atom.md b/product-docs/10-magistrala-on-atom.md index 5e6706d..7186a26 100644 --- a/product-docs/10-magistrala-on-atom.md +++ b/product-docs/10-magistrala-on-atom.md @@ -100,7 +100,7 @@ Create an Atom tenant. The tenant ID is the Magistrala domain ID. { "id": "domain-uuid", "name": "factory-1", - "route": "factory-1", + "alias": "factory-1", "attributes": { "magistrala": { "metadata": { @@ -163,11 +163,11 @@ Create a tenant-owned resource. { "kind": "channel", "name": "temperature", + "alias": "temperature", "tenant_id": "domain-uuid", "owner_id": "alice-entity-id", "attributes": { "magistrala": { - "route": "factory-1.temperature", "status": "enabled", "tags": ["temperature"] } diff --git a/product-docs/PRD.md b/product-docs/PRD.md index 481a58c..c320093 100644 --- a/product-docs/PRD.md +++ b/product-docs/PRD.md @@ -166,7 +166,7 @@ Needs: ### Tenant -A tenant is a first-class isolation boundary with `name`, optional `route`, `tags`, `attributes`, lifecycle status, and audit fields. +A tenant is a first-class isolation boundary with `name`, optional `alias`, `tags`, `attributes`, lifecycle status, and audit fields. Status values: diff --git a/proto/atom/v1/atom.proto b/proto/atom/v1/atom.proto index e8c535b..a8cdadb 100644 --- a/proto/atom/v1/atom.proto +++ b/proto/atom/v1/atom.proto @@ -87,19 +87,21 @@ message RevokeEntityCertificatesResponse { } message ResolveAliasRequest { - // Tenant selector — exactly one of these identifies the domain. tenant_id - // wins if both are set; tenant_alias is the case-folded tenant slug. + // Tenant selector — exactly one of tenant_id, tenant_alias, or global must + // be set. tenant_alias is the case-folded tenant slug. string tenant_id = 1; string tenant_alias = 2; // Which table the object alias addresses: "entity" (clients/devices) or - // "resource" (channels). Anything other than "entity" is treated as a - // resource. Generic on purpose — no domain/channel vocabulary. + // "resource" (channels). Other values are rejected. Generic on purpose — + // no domain/channel vocabulary. string object_kind = 3; // The object's alias slug, unique within the tenant. string object_alias = 4; + // Resolve an entity or resource whose tenant_id is NULL. + bool global = 5; } message ResolveAliasResponse { - string tenant_id = 1; + string tenant_id = 1; // empty string for global objects string object_id = 2; } diff --git a/src/authz/repo.rs b/src/authz/repo.rs index 0321fa2..5ae2e99 100644 --- a/src/authz/repo.rs +++ b/src/authz/repo.rs @@ -198,13 +198,15 @@ pub async fn update_resource( .and_then(|attrs| attrs.get("parent_group_id")) .map(parent_group_id_from_value) .transpose()?; - let alias = crate::models::alias::validate_alias_opt(req.alias)?; + let alias = crate::models::alias::validate_alias_update(req.alias)?; + let alias_is_set = alias.is_some(); + let alias = alias.flatten(); let mut tx = pool.begin().await.map_err(db_err)?; let resource = sqlx::query_as::<_, Resource>( r#"UPDATE resources SET name = COALESCE($2, name), attributes = COALESCE($3, attributes), - alias = COALESCE($4, alias), + alias = CASE WHEN $4 THEN $5 ELSE alias END, updated_at = now() WHERE id = $1 RETURNING id, kind, name, alias, tenant_id, owner_id, attributes, created_at, updated_at"#, @@ -212,6 +214,7 @@ pub async fn update_resource( .bind(id) .bind(req.name) .bind(req.attributes) + .bind(alias_is_set) .bind(alias) .fetch_one(&mut *tx) .await @@ -243,10 +246,10 @@ pub async fn delete_resource(pool: &PgPool, id: Uuid) -> Result<(), AppError> { Ok(()) } -/// The UUIDs a alias path resolves to. +/// The UUIDs an alias path resolves to. #[derive(Debug, Clone, Copy)] pub struct ResolvedAlias { - pub tenant_id: Uuid, + pub tenant_id: Option, pub object_id: Uuid, } @@ -254,6 +257,7 @@ pub struct ResolvedAlias { /// /// Two-level: first resolve the tenant (by id, or case-folded `alias`), then the /// object (entity or resource) by its case-folded `alias` within that tenant. +/// Global objects are selected explicitly and resolve with no tenant UUID. /// Resolution is capability-neutral — it reveals only the UUIDs; the actual /// authorization gate is the subsequent `authz` check by UUID. Returns /// `NotFound` if either level is missing. @@ -261,22 +265,30 @@ pub async fn resolve_alias( pool: &PgPool, tenant_id: Option, tenant_alias: Option<&str>, + global: bool, class: AliasObjectClass, object_alias: &str, ) -> Result { - let tenant_id = match (tenant_id, tenant_alias) { - (Some(id), _) => id, - (None, Some(alias)) => { - sqlx::query_scalar::<_, Uuid>("SELECT id FROM tenants WHERE lower(alias) = lower($1)") - .bind(alias) - .fetch_optional(pool) - .await - .map_err(db_err)? - .ok_or_else(|| AppError::not_found(format!("tenant alias '{alias}' not found")))? + let tenant_alias = tenant_alias + .map(str::trim) + .filter(|alias| !alias.is_empty()); + let tenant_id = match (tenant_id, tenant_alias, global) { + (Some(id), None, false) => Some(id), + (None, Some(alias), false) => { + let id = sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM tenants WHERE lower(alias) = lower($1)", + ) + .bind(alias) + .fetch_optional(pool) + .await + .map_err(db_err)? + .ok_or_else(|| AppError::not_found(format!("tenant alias '{alias}' not found")))?; + Some(id) } - (None, None) => { + (None, None, true) => None, + _ => { return Err(AppError::bad_request( - "provide tenant_id or tenant_alias to resolve a alias", + "provide exactly one tenant selector: tenant_id, tenant_alias, or global", )) } }; @@ -289,12 +301,12 @@ pub async fn resolve_alias( let sql = match class { AliasObjectClass::Entity => { "SELECT id FROM entities \ - WHERE COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid) = $1 \ + WHERE tenant_id IS NOT DISTINCT FROM $1::uuid \ AND lower(alias) = $2" } AliasObjectClass::Resource => { "SELECT id FROM resources \ - WHERE COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid) = $1 \ + WHERE tenant_id IS NOT DISTINCT FROM $1::uuid \ AND lower(alias) = $2" } }; @@ -306,9 +318,10 @@ pub async fn resolve_alias( .await .map_err(db_err)? .ok_or_else(|| { - AppError::not_found(format!( - "alias '{object_alias}' not found in tenant {tenant_id}" - )) + let scope = tenant_id + .map(|id| format!("tenant {id}")) + .unwrap_or_else(|| "global scope".to_string()); + AppError::not_found(format!("alias '{object_alias}' not found in {scope}")) })?; Ok(ResolvedAlias { diff --git a/src/graphql/entities.rs b/src/graphql/entities.rs index 30c308b..1b4f39b 100644 --- a/src/graphql/entities.rs +++ b/src/graphql/entities.rs @@ -195,7 +195,7 @@ impl EntityMutation { entity_model::UpdateEntity { name: input.name, kind: parse_optional_entity_kind(input.kind), - alias: input.alias, + alias: input.alias.into(), tenant_id: parse_optional_id(input.tenant_id, "tenantId")?, profile_id: parse_optional_id(input.profile_id, "profileId")?, profile_version_id: parse_optional_id( diff --git a/src/graphql/resources.rs b/src/graphql/resources.rs index 3f6d6d3..ea535bb 100644 --- a/src/graphql/resources.rs +++ b/src/graphql/resources.rs @@ -173,7 +173,7 @@ impl ResourceMutation { id, UpdateResource { name: input.name, - alias: input.alias, + alias: input.alias.into(), attributes: input.attributes, }, ) diff --git a/src/graphql/tenants.rs b/src/graphql/tenants.rs index 7ff93ec..c54a2a7 100644 --- a/src/graphql/tenants.rs +++ b/src/graphql/tenants.rs @@ -281,7 +281,7 @@ impl TenantMutation { tenant_id, tenant_model::UpdateTenant { name: input.name, - alias: input.alias, + alias: input.alias.into(), tags: input.tags, attributes: input.attributes, }, diff --git a/src/graphql/types/mod.rs b/src/graphql/types/mod.rs index 95b2bee..0a04b71 100644 --- a/src/graphql/types/mod.rs +++ b/src/graphql/types/mod.rs @@ -1,4 +1,4 @@ -use async_graphql::{Context, Enum, InputObject, Object, Result, ID}; +use async_graphql::{Context, Enum, InputObject, MaybeUndefined, Object, Result, ID}; use chrono::{DateTime, Utc}; use serde_json::Value; use uuid::Uuid; @@ -1457,7 +1457,7 @@ pub struct CreateEntityInput { pub struct UpdateEntityInput { pub name: Option, pub kind: Option, - pub alias: Option, + pub alias: MaybeUndefined, pub tenant_id: Option, pub profile_id: Option, pub profile_version_id: Option, @@ -1477,7 +1477,7 @@ pub struct CreateTenantInput { #[derive(InputObject)] pub struct UpdateTenantInput { pub name: Option, - pub alias: Option, + pub alias: MaybeUndefined, pub tags: Option>, pub attributes: Option, } @@ -1510,7 +1510,7 @@ pub struct CreateResourceInput { #[derive(InputObject)] pub struct UpdateResourceInput { pub name: Option, - pub alias: Option, + pub alias: MaybeUndefined, pub attributes: Option, } diff --git a/src/grpc.rs b/src/grpc.rs index ef89cd2..101edb2 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -287,16 +287,15 @@ impl AliasService for AtomAlias { }; let tenant_alias = (!req.tenant_alias.is_empty()).then_some(req.tenant_alias.as_str()); - let class = if req.object_kind.eq_ignore_ascii_case("entity") { - AliasObjectClass::Entity - } else { - AliasObjectClass::Resource - }; + let class = parse_alias_object_class(&req.object_kind).ok_or_else(|| { + Status::invalid_argument("invalid object_kind: expected 'entity' or 'resource'") + })?; let resolved = repo::resolve_alias( &self.state.pool, tenant_id, tenant_alias, + req.global, class, &req.object_alias, ) @@ -304,12 +303,26 @@ impl AliasService for AtomAlias { .map_err(Status::from)?; Ok(Response::new(ResolveAliasResponse { - tenant_id: resolved.tenant_id.to_string(), + tenant_id: resolved + .tenant_id + .map(|id| id.to_string()) + .unwrap_or_default(), object_id: resolved.object_id.to_string(), })) } } +fn parse_alias_object_class(value: &str) -> Option { + let value = value.trim(); + if value.eq_ignore_ascii_case("entity") { + Some(AliasObjectClass::Entity) + } else if value.eq_ignore_ascii_case("resource") { + Some(AliasObjectClass::Resource) + } else { + None + } +} + // ─── Server ─────────────────────────────────────────────────────────────────── pub async fn bind_listener(addr: SocketAddr) -> std::io::Result { @@ -438,6 +451,19 @@ mod tests { ); } + #[test] + fn alias_object_kind_rejects_unknown_values() { + assert_eq!( + parse_alias_object_class("entity"), + Some(AliasObjectClass::Entity) + ); + assert_eq!( + parse_alias_object_class(" RESOURCE "), + Some(AliasObjectClass::Resource) + ); + assert_eq!(parse_alias_object_class("entitiy"), None); + } + async fn health_client(addr: SocketAddr) -> HealthClient { let endpoint = format!("http://{addr}"); for _ in 0..20 { diff --git a/src/identity/repo.rs b/src/identity/repo.rs index 395ad99..337e58d 100644 --- a/src/identity/repo.rs +++ b/src/identity/repo.rs @@ -205,7 +205,9 @@ pub async fn update_entity(pool: &PgPool, id: Uuid, req: UpdateEntity) -> Result validate_existing_entity_attributes(pool, id, attrs).await?; } - let alias = crate::models::alias::validate_alias_opt(req.alias)?; + let alias = crate::models::alias::validate_alias_update(req.alias)?; + let alias_is_set = alias.is_some(); + let alias = alias.flatten(); let mut tx = pool.begin().await.map_err(db_err)?; let entity = sqlx::query_as::<_, Entity>( @@ -217,7 +219,7 @@ pub async fn update_entity(pool: &PgPool, id: Uuid, req: UpdateEntity) -> Result profile_version_id = COALESCE($6, profile_version_id), status = COALESCE($7, status), attributes = COALESCE($8, attributes), - alias = COALESCE($9, alias), + alias = CASE WHEN $9 THEN $10 ELSE alias END, updated_at = now() WHERE id = $1 RETURNING id, kind, name, alias, tenant_id, profile_id, profile_version_id, @@ -231,6 +233,7 @@ pub async fn update_entity(pool: &PgPool, id: Uuid, req: UpdateEntity) -> Result .bind(req.profile_version_id) .bind(req.status) .bind(attributes) + .bind(alias_is_set) .bind(alias) .fetch_one(&mut *tx) .await diff --git a/src/models/alias.rs b/src/models/alias.rs index b206f42..32e4182 100644 --- a/src/models/alias.rs +++ b/src/models/alias.rs @@ -7,6 +7,7 @@ //! alias-addressing and id-addressing can never collide. use crate::error::AppError; +use serde::{Deserialize, Deserializer}; /// Maximum slug length (DNS-label-like). pub const MAX_ALIAS_LEN: usize = 63; @@ -68,6 +69,25 @@ pub fn validate_alias_opt(alias: Option) -> Result, AppEr } } +/// Validate an optional alias update while preserving patch semantics: +/// `None` leaves the field unchanged, `Some(None)` clears it, and +/// `Some(Some(value))` validates and stores the normalized alias. +pub fn validate_alias_update( + alias: Option>, +) -> Result>, AppError> { + alias.map(validate_alias_opt).transpose() +} + +/// Serde helper for PATCH-style alias fields. A missing field remains +/// `None`, explicit JSON `null` becomes `Some(None)`, and a string becomes +/// `Some(Some(value))`. +pub fn deserialize_alias_update<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(Some) +} + fn is_uuid_shaped(s: &str) -> bool { uuid::Uuid::parse_str(s).is_ok() } @@ -108,4 +128,37 @@ mod tests { Some("chan".to_string()) ); } + + #[test] + fn update_preserves_undefined_clear_and_value_states() { + assert_eq!(validate_alias_update(None).unwrap(), None); + assert_eq!(validate_alias_update(Some(None)).unwrap(), Some(None)); + assert_eq!( + validate_alias_update(Some(Some(" Sensor-01 ".into()))).unwrap(), + Some(Some("sensor-01".to_string())) + ); + } + + #[test] + fn patch_deserialization_distinguishes_missing_null_and_value() { + #[derive(Deserialize)] + struct Patch { + #[serde(default, deserialize_with = "deserialize_alias_update")] + alias: Option>, + } + + assert_eq!(serde_json::from_str::("{}").unwrap().alias, None); + assert_eq!( + serde_json::from_str::(r#"{"alias":null}"#) + .unwrap() + .alias, + Some(None) + ); + assert_eq!( + serde_json::from_str::(r#"{"alias":"sensor"}"#) + .unwrap() + .alias, + Some(Some("sensor".to_string())) + ); + } } diff --git a/src/models/entity.rs b/src/models/entity.rs index a5df8d8..8c1ef18 100644 --- a/src/models/entity.rs +++ b/src/models/entity.rs @@ -37,7 +37,11 @@ pub struct CreateEntity { pub struct UpdateEntity { pub name: Option, pub kind: Option, - pub alias: Option, + #[serde( + default, + deserialize_with = "crate::models::alias::deserialize_alias_update" + )] + pub alias: Option>, pub tenant_id: Option, pub profile_id: Option, pub profile_version_id: Option, diff --git a/src/models/resource.rs b/src/models/resource.rs index 75e5e76..4313ce0 100644 --- a/src/models/resource.rs +++ b/src/models/resource.rs @@ -31,7 +31,11 @@ pub struct CreateResource { #[derive(Debug, Deserialize)] pub struct UpdateResource { pub name: Option, - pub alias: Option, + #[serde( + default, + deserialize_with = "crate::models::alias::deserialize_alias_update" + )] + pub alias: Option>, pub attributes: Option, } diff --git a/src/models/tenant.rs b/src/models/tenant.rs index 2015428..70c0a23 100644 --- a/src/models/tenant.rs +++ b/src/models/tenant.rs @@ -33,7 +33,11 @@ pub struct CreateTenant { #[derive(Debug, Deserialize)] pub struct UpdateTenant { pub name: Option, - pub alias: Option, + #[serde( + default, + deserialize_with = "crate::models::alias::deserialize_alias_update" + )] + pub alias: Option>, pub tags: Option>, pub attributes: Option, } diff --git a/src/tenants/repo.rs b/src/tenants/repo.rs index d6523f6..fc14cc6 100644 --- a/src/tenants/repo.rs +++ b/src/tenants/repo.rs @@ -443,20 +443,23 @@ pub async fn update_tenant( req: UpdateTenant, updated_by: Option, ) -> Result { - let alias = crate::models::alias::validate_alias_opt(req.alias)?; + let alias = crate::models::alias::validate_alias_update(req.alias)?; + let alias_is_set = alias.is_some(); + let alias = alias.flatten(); sqlx::query_as::<_, Tenant>(&format!( r#"UPDATE tenants SET name = COALESCE($2, name), - alias = COALESCE($3, alias), - tags = COALESCE($4, tags), - attributes = COALESCE($5, attributes), - updated_by = $6, + alias = CASE WHEN $3 THEN $4 ELSE alias END, + tags = COALESCE($5, tags), + attributes = COALESCE($6, attributes), + updated_by = $7, updated_at = now() WHERE id = $1 RETURNING {TENANT_COLS}"#, )) .bind(id) .bind(req.name) + .bind(alias_is_set) .bind(alias) .bind(req.tags) .bind(req.attributes) diff --git a/tests/m11_graphql_primitives.rs b/tests/m11_graphql_primitives.rs index faabb56..1182644 100644 --- a/tests/m11_graphql_primitives.rs +++ b/tests/m11_graphql_primitives.rs @@ -300,6 +300,24 @@ async fn create_list_and_get_tenant() { tenant_id ); + let cleared = schema + .execute(authed(format!( + r#" + mutation {{ + updateTenant(id: "{tenant_id}", input: {{ alias: null }}) {{ + id + alias + }} + }} + "# + ))) + .await; + assert!(cleared.errors.is_empty(), "{:?}", cleared.errors); + assert!( + cleared.data.into_json().expect("json data")["updateTenant"]["alias"].is_null(), + "explicit GraphQL null must clear an existing alias" + ); + delete_tenant_row(&pool, tenant_id.parse().expect("tenant uuid")).await; } diff --git a/tests/m18_aliases.rs b/tests/m18_aliases.rs index c8f6f57..a6aa30f 100644 --- a/tests/m18_aliases.rs +++ b/tests/m18_aliases.rs @@ -13,9 +13,12 @@ mod common; use common::pool; use atom::authz::repo as authz_repo; +use atom::identity::repo as identity_repo; use atom::models::alias::AliasObjectClass; -use atom::models::resource::CreateResource; -use atom::models::tenant::CreateTenant; +use atom::models::entity::{CreateEntity, UpdateEntity}; +use atom::models::enums::EntityKind; +use atom::models::resource::{CreateResource, UpdateResource}; +use atom::models::tenant::{CreateTenant, UpdateTenant}; use atom::tenants::repo as tenant_repo; use serde_json::json; use uuid::Uuid; @@ -90,17 +93,19 @@ async fn resolve_alias_resolves_tenant_and_object() { let resource = authz_repo::create_resource(&p, resource_req(tenant_id, &object_alias)) .await .expect("create resource"); + let tenant_lookup = format!(" {} ", tenant_alias.to_uppercase()); let resolved = authz_repo::resolve_alias( &p, None, - Some(&tenant_alias), + Some(&tenant_lookup), + false, AliasObjectClass::Resource, &object_alias, ) .await .expect("resolve by tenant alias + object alias"); - assert_eq!(resolved.tenant_id, tenant_id); + assert_eq!(resolved.tenant_id, Some(tenant_id)); assert_eq!(resolved.object_id, resource.id); // Unknown object alias → NotFound. @@ -108,6 +113,7 @@ async fn resolve_alias_resolves_tenant_and_object() { &p, Some(tenant_id), None, + false, AliasObjectClass::Resource, "does-not-exist", ) @@ -130,6 +136,7 @@ async fn resolve_alias_is_case_insensitive() { &p, Some(tenant_id), None, + false, AliasObjectClass::Resource, "WaterMeters", ) @@ -138,6 +145,152 @@ async fn resolve_alias_is_case_insensitive() { assert_eq!(resolved.object_id, resource.id); } +#[tokio::test] +#[ignore] +async fn resolve_alias_supports_explicit_global_scope() { + let p = pool().await; + let object_alias = slug("global"); + let resource = authz_repo::create_resource( + &p, + CreateResource { + id: None, + kind: "resource:global".to_string(), + name: Some("global".to_string()), + alias: Some(object_alias.clone()), + tenant_id: None, + owner_id: None, + attributes: json!({}), + }, + ) + .await + .expect("create global resource"); + + let resolved = authz_repo::resolve_alias( + &p, + None, + None, + true, + AliasObjectClass::Resource, + &object_alias, + ) + .await + .expect("resolve global resource"); + + assert_eq!(resolved.tenant_id, None); + assert_eq!(resolved.object_id, resource.id); +} + +#[tokio::test] +#[ignore] +async fn alias_updates_can_clear_existing_values() { + let p = pool().await; + let tenant_id = make_tenant(&p, &slug("tenant")).await; + let entity = identity_repo::create_entity( + &p, + CreateEntity { + id: None, + kind: Some(EntityKind::Device), + profile_id: None, + profile_version_id: None, + name: slug("device"), + alias: Some(slug("entity")), + tenant_id: Some(tenant_id), + attributes: json!({}), + }, + ) + .await + .expect("create entity"); + let resource = authz_repo::create_resource(&p, resource_req(tenant_id, &slug("resource"))) + .await + .expect("create resource"); + + let entity = identity_repo::update_entity( + &p, + entity.id, + UpdateEntity { + name: None, + kind: None, + alias: Some(None), + tenant_id: None, + profile_id: None, + profile_version_id: None, + status: None, + attributes: None, + }, + ) + .await + .expect("clear entity alias"); + let resource = authz_repo::update_resource( + &p, + resource.id, + UpdateResource { + name: None, + alias: Some(None), + attributes: None, + }, + ) + .await + .expect("clear resource alias"); + let tenant = tenant_repo::update_tenant( + &p, + tenant_id, + UpdateTenant { + name: None, + alias: Some(None), + tags: None, + attributes: None, + }, + None, + ) + .await + .expect("clear tenant alias"); + + assert_eq!(entity.alias, None); + assert_eq!(resource.alias, None); + assert_eq!(tenant.alias, None); +} + +#[tokio::test] +#[ignore] +async fn database_rejects_uuid_shaped_aliases() { + let p = pool().await; + let tenant_id = make_tenant(&p, &slug("tenant")).await; + let uuid_alias = "465358f9-07f4-4ea0-8cbb-2abc654442bd"; + + for result in [ + sqlx::query("INSERT INTO tenants (name, alias) VALUES ($1, $2)") + .bind(slug("bad-tenant")) + .bind(uuid_alias) + .execute(&p) + .await, + sqlx::query( + "INSERT INTO entities (kind, name, alias, tenant_id) \ + VALUES ('device', $1, $2, $3)", + ) + .bind(slug("bad-entity")) + .bind(uuid_alias) + .bind(tenant_id) + .execute(&p) + .await, + sqlx::query( + "INSERT INTO resources (kind, name, alias, tenant_id) \ + VALUES ('resource:channel', $1, $2, $3)", + ) + .bind("bad resource") + .bind(uuid_alias) + .bind(tenant_id) + .execute(&p) + .await, + ] { + let err = result.expect_err("UUID-shaped alias must violate a CHECK constraint"); + let code = err + .as_database_error() + .and_then(|db| db.code()) + .map(|code| code.into_owned()); + assert_eq!(code.as_deref(), Some("23514")); + } +} + #[tokio::test] #[ignore] async fn create_resource_rejects_invalid_aliases() { From 1dd870a043b2611cc01dfe17e7659f7e8b0fc301 Mon Sep 17 00:00:00 2001 From: dusan Date: Fri, 19 Jun 2026 16:23:10 +0200 Subject: [PATCH 3/3] Improve migrations Signed-off-by: dusan --- migrations/005_alias_uuid_guardrails.sql | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/migrations/005_alias_uuid_guardrails.sql b/migrations/005_alias_uuid_guardrails.sql index a2e05c8..35b2225 100644 --- a/migrations/005_alias_uuid_guardrails.sql +++ b/migrations/005_alias_uuid_guardrails.sql @@ -2,6 +2,34 @@ -- Aliases use the same character set as UUID text, so the slug CHECK alone -- would otherwise allow canonical or compact UUID strings. +---------------------------------------------------------------------- +-- Pre-flight: fail LOUD if any existing alias is UUID-shaped. +-- +-- A UUID-shaped string (e.g. a legacy tenant route, or a value from a +-- direct SQL/import path) is a valid slug, so it survives migration 004 +-- but would collide with id-addressing. Catch it here with operator +-- guidance rather than letting the ADD CONSTRAINT below abort with a raw +-- check-violation error. Matches validate_alias() in the app layer. +---------------------------------------------------------------------- + +DO $$ +DECLARE + uuid_re text := '^([0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'; + bad_count bigint; +BEGIN + SELECT + (SELECT count(*) FROM tenants WHERE alias IS NOT NULL AND lower(alias) ~ uuid_re) + + (SELECT count(*) FROM entities WHERE alias IS NOT NULL AND lower(alias) ~ uuid_re) + + (SELECT count(*) FROM resources WHERE alias IS NOT NULL AND lower(alias) ~ uuid_re) + INTO bad_count; + + IF bad_count > 0 THEN + RAISE EXCEPTION + 'Migration 005: % alias(es) are UUID-shaped, which collides with id-addressing. Rename or clear them before migrating.', + bad_count; + END IF; +END $$; + ALTER TABLE tenants ADD CONSTRAINT chk_tenants_alias_not_uuid CHECK (