Repository navigation
console tenancy design
This documentation captures the implementation introduced to support multi-tenancy in Console, covering the high-level design decisions and implementation details behind them.
-
Tenant identifier format and validation: tenant IDs must match the regular expression
^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$. This allows 1–64 characters fromA-Z,a-z,0-9,_, and-(hyphen), with the first character required to be alphanumeric. The final character may be alphanumeric or one of_or-(hyphen). Tenant scope comes from the validatedx-tenant-idheader; request bodies do not provide tenant scope. Detailed implementation: §2.1. -
REST propagation: protected REST APIs read the tenant from the
x-tenant-idheader, validate it before handler execution, and expose it through middleware. Controllers pass the resolved tenant explicitly to tenant-aware feature and management methods. Device lookups use the tenant at the repository boundary, so the compiler-visible interface contract prevents accidental omission. Detailed implementation: §2.2. - API contract and test artifacts: OpenAPI and Postman test artifacts describe and exercise the same tenant header, validation rules, and isolation behavior as the runtime implementation. Detailed implementation: §2.3.
The high-level decisions above are expanded below. Each subsection describes the corresponding implementation.
The exact validation expression for the tenant-id is:
^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$Meaning:
-
^anchors the match at the beginning of the value. -
[A-Za-z0-9]requires the first character to be uppercase, lowercase, or a digit. -
[A-Za-z0-9_-]allows uppercase letters, lowercase letters, digits, underscore, and hyphen after the first character. -
{0,63}allows zero to 63 additional characters, for a total length of 1–64. -
$anchors the match at the end of the value.
The first character must be alphanumeric. Therefore .tenant, _tenant, and -tenant are
invalid, while tenant_ and tenant- are valid. Values containing whitespace, ., /, :,
quotes, Unicode characters, or more than 64 characters are invalid. The empty string is
handled separately as the default tenant and is allowed when no header is supplied.
The feature implementation uses this expression in the shared validator before the policy can be considered complete.
- Invalid tenant values return
400 Bad Request. - Validated values are stored by HTTP middleware and passed explicitly by controllers.
- Header-less requests preserve the existing empty-tenant behavior on the main feature branch.
The validation rule is shared with OpenAPI documentation through the middleware tenant hint, preventing the runtime error message and API description from drifting apart.
The format intentionally excludes whitespace, control characters, SQL-like punctuation, path separators, and Unicode homoglyphs. This prevents visually confusing values from becoming distinct composite-key identifiers. The current maximum is 64 characters and can be increased later if a deployment requires it.
Behavior is:
- A valid header supplies the tenant scope for the request.
- An invalid header returns
400 Bad Requestbefore the handler runs. - An absent header selects the default empty tenant for backwards compatibility.
The following handlers now read the validated tenant from middleware state and pass it to their feature methods for list, count, get-by-name, insert, update, export, and delete operations:
-
/api/v1/devices: Devices, including list, count, tags, lookup, certificate lookup, update, and delete. -
/api/v1/admin/profiles: Profiles, including list, count, lookup, export, update, and delete. -
/api/v1/admin/domains: Domains, including list, count, lookup, update, and delete. -
/api/v1/admin/ciraconfigs: CIRA configurations, including list, count, lookup, update, and delete. -
/api/v1/admin/wirelessconfigs: Wireless configurations, including list, count, lookup, update, and delete. -
/api/v1/admin/ieee8021xconfigs: IEEE 802.1x configurations, including list, count, lookup, update, and delete.
All 41 management calls on devices.Feature (GetPowerState, SendPowerAction, Redirect,
GetAuditLog, GetCertificates, …) opened with a hardcoded, unscoped lookup:
func (uc *UseCase) GetVersion(c context.Context, guid string) (v1 dto.Version, v2 dtov2.Version, err error) {
item, err := uc.repo.GetByID(c, guid, "") // <- tenant hardcoded to ""
if err != nil {
return v1, v2, err
}
device, err := uc.device.SetupWsmanClient(c, *item, false, true)
...Every /api/v1/amt/* endpoint was therefore unscoped. A caller presenting
x-tenant-id: tenant-a could power-cycle, deactivate, KVM into, or read the audit log of a
device belonging to tenant-b merely by knowing its GUID — horizontal privilege escalation on
a control plane that can physically reset machines.
The key observation: tenantID is needed in exactly one place per method — that opening
lookup. It never influences the WSMAN call, the DTO mapping, or anything downstream.
Two design options were considered:
-
Adding
tenantID stringto all 41 device-management interface methods, approximately 45 handlers, and roughly 330 test expectations, (or) -
Storing the validated tenant in request context and using one scoped lookup helper.
| Option 1 — explicit parameters (chosen) | Option 2 — context + scoped lookup | |
|---|---|---|
| Interface methods changed | 41 | 0 |
| Handler call sites | ~45 | 0 |
| Test call sites | ~330 | 0 |
| Mock regeneration | required | none |
| Missed call site | compile error | silent |
Option 1 adds tenantID string through every signature:
// interfaces.go
- GetPowerState(ctx context.Context, guid string) (dto.PowerState, error)
+ GetPowerState(ctx context.Context, guid, tenantID string) (dto.PowerState, error)
// power.go
-func (uc *UseCase) GetPowerState(c context.Context, guid string) (dto.PowerState, error) {
- item, err := uc.repo.GetByID(c, guid, "")
+func (uc *UseCase) GetPowerState(c context.Context, guid, tenantID string) (dto.PowerState, error) {
+ item, err := uc.repo.GetByID(c, guid, tenantID)
// httpapi/v1/power.go
- state, err := dr.d.GetPowerState(c.Request.Context(), guid)
+ tenantID := middleware.TenantID(c)
+ state, err := dr.d.GetPowerState(c.Request.Context(), guid, tenantID)
// every one of ~330 test call sites
- device.EXPECT().GetPowerState(context.Background(), "guid").Return(...)
+ device.EXPECT().GetPowerState(context.Background(), "guid", "").Return(...)…repeated 41 times, plus a 1237-line regenerated mock.
The middleware owns header extraction and validation. It stores the resolved value in Gin request
state through middleware.TenantID(c), but it does not place the tenant in context.Context.
Controllers pass the tenant explicitly through the devices.Feature interface and its handlers.
For example:
// internal/controller/httpapi/v1/power.go
tenantID := middleware.TenantID(c)
state, err := r.d.GetPowerState(c.Request.Context(), guid, tenantID)
// internal/usecase/devices/power.go
func (uc *UseCase) GetPowerState(c context.Context, guid, tenantID string) (dto.PowerState, error) {
item, err := uc.repo.GetByID(c, guid, tenantID)
// WSMAN and response mapping continue unchanged.
}The tenant is threaded through all tenant-aware devices.Feature management methods, including
power, boot, certificates, consent, network, wireless, KVM, redirection, and audit/event logs.
Generated mocks and unit tests are updated with the new parameter. CIRA authentication remains
GUID-based because a device connection does not carry an HTTP tenant header.
Request:
GET /api/v1/amt/power/state/4c4c4544-0043-4810-8053-b8c04f595931 HTTP/1.1
Authorization: Bearer <jwt>
x-tenant-id: acme-corpFlow:
| Step | Component | Action |
|---|---|---|
| 1 | login.JWTAuthMiddleware |
Authenticates the caller. |
| 2 | middleware.ResolveTenant |
Validates "acme-corp" and stores it in Gin request state. |
| 3 | httpapi/v1/power.go |
middleware.TenantID(c) → "acme-corp"; passes it to GetPowerState. |
| 4 | usecase/devices/power.go |
Receives the explicit tenantID parameter. |
| 5 |
usecase/devices repository boundary |
Calls uc.repo.GetByID(ctx, guid, "acme-corp"). |
| 6 | usecase/sqldb/device.go |
WHERE guid = ? AND tenantid = ? → ('4c4c...', 'acme-corp'). |
The Go context package documentation says contexts carry
deadlines, cancellation signals, and request-scoped values across API boundaries. It also
specifically advises: “Use context Values only for request-scoped data that transits processes
and API boundaries, not for passing optional parameters to functions.”
Tenant scope is an input to a management operation, not an optional transport value hidden in
the context. Passing it explicitly makes the dependency visible in devices.Feature, gives the
compiler a chance to catch missed call sites, and makes non-HTTP callers choose a tenant rather
than silently falling back to a context default.
The internal/tenant package, context key, context helpers, and device lookup helper were
removed entirely. Version-neutral tenant extraction remains in
internal/controller/httpapi/middleware, while feature interfaces own the explicit propagation
required by their operations.
With no header, middleware resolves the empty tenant and handlers pass "" explicitly, preserving
existing single-tenant behavior. When a header is present, the tenant is included in every
tenant-aware management lookup.
This design makes the security decision visible at the feature interface and repository query boundary while keeping CIRA's GUID-only authentication flow separate from HTTP tenant scope.
The Explorer HTTP handler now passes middleware.TenantID(c) to the already tenant-aware
ExecuteCall feature method. This removed the last hardcoded empty tenant in the controller
layer. The feature interface is amtexplorer.Feature,
where ExecuteCall already accepted tenantID.
protectedRouteOptions now declares the optional x-tenant-id header once for every protected
operation and documents the accepted format and acme-corp example. It also declares the
400 Bad Request response for validation failures.
The generated doc/openapi.json is intentionally not committed because the repository
ignores it and CI generates it. A spec test walks all operations and verifies that the header
exists on protected routes and is absent from the two public authorization endpoints.
Both collections now have a collection-level pre-request hook:
const tenantId = pm.variables.get('tenantId');
if (tenantId) {
pm.request.headers.upsert({ key: 'x-tenant-id', value: tenantId });
}The environment includes tenantId, tenantA, and tenantB. New Tenancy folders cover:
- Tenant-scoped device list, read, and tag operations.
- Tenant-scoped profile creation/read/delete isolation scenarios.
- Cross-tenant
404behavior. - Invalid header
400behavior and body tenant IDs being ignored. - Malformed tenant values and the 64/65-character boundary