-
Notifications
You must be signed in to change notification settings - Fork 13
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); header/body conflicts are rejected with a 400 response. Detailed implementation: §2.1. -
REST propagation: protected REST APIs read the tenant from the
x-tenant-idheader, validate it before handler execution, and store it in request context. A single scoped lookup helper keeps CRUD, device-management, and Explorer operations tenant-isolated without changing existing feature interfaces. Detailed implementation: §2.2. -
CIRA tenant source: CIRA devices authenticate through APF using their GUID and MPS
credentials, so they cannot provide an HTTP tenant header. Resolve the device by GUID,
by learning the tenant from the authenticated database row, storing it on the connection, and
rejecting a REST caller from a different tenant with
401 Unauthorized, matching MPS. Detailed implementation: §2.3. -
WebSocket tenant source: KVM, SOL, and IDE-R use a relay outside the
/apigroup, and browsers cannot send a custom tenant header. The implemented flow derives the tenant from the redirection JWT, which is bound to the device GUID, and propagates it through request context before the relay calls the device feature. Detailed implementation: §2.4. - 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.5.
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 placed on the request context for downstream use cases.
- Header-less requests preserve the existing empty-tenant behavior on the main feature branch.
The validation rule is shared with OpenAPI documentation through 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:
- Header present and body empty: header is applied.
- Header present and body matches: request is accepted.
- Header present and body differs: request returns
400. - Header absent: the existing body value remains unchanged.
The following handlers now read the validated tenant from request context 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 | Option 2 — context + scoped lookup (chosen) | |
|---|---|---|
| Interface methods changed | 41 | 0 |
| Handler call sites | ~45 | 0 |
| Test call sites | ~330 | 0 |
| Mock regeneration | 1237 lines | none |
| Actual diff | ~1500 lines (est.) | +307 / −174 |
| 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)
+ state, err := dr.d.GetPowerState(c.Request.Context(), guid, tenantIDFromHeader(c))
// 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.
A dependency-free internal/tenant package holds the context key and the validation rule:
// internal/tenant/tenant.go
package tenant
const MaxLength = 64
const Hint = "x-tenant-id must be 1-64 characters of A-Z, a-z, 0-9, underscore or hyphen"
var pattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
type contextKey struct{}
func Valid(tenantID string) bool { return tenantID == "" || pattern.MatchString(tenantID) }
func WithContext(ctx context.Context, tenantID string) context.Context {
return context.WithValue(ctx, contextKey{}, tenantID)
}
func FromContext(ctx context.Context) string {
tenantID, _ := ctx.Value(contextKey{}).(string)
return tenantID
}The middleware validates once and scopes the request context:
// internal/controller/httpapi/middleware/tenant.go
func Tenant() gin.HandlerFunc {
return func(c *gin.Context) {
tenantID := c.GetHeader(TenantHeaderName)
if !tenant.Valid(tenantID) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": tenant.Hint, "message": tenant.Hint})
return
}
// Untouched for the default tenant, so single-tenant requests carry no extra value.
if tenantID != "" {
c.Request = c.Request.WithContext(tenant.WithContext(c.Request.Context(), tenantID))
}
c.Next()
}
}The use case gains one choke point:
// internal/usecase/devices/tenant.go
func (uc *UseCase) deviceInTenant(ctx context.Context, guid string) (*entity.Device, error) {
return uc.repo.GetByID(ctx, guid, tenant.FromContext(ctx))
}And all 35 call sites across 14 files become a one-liner each:
- item, err := uc.repo.GetByID(c, guid, "")
+ item, err := uc.deviceInTenant(c, guid)No interface change. No handler change. No test change. No mock regeneration.
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.Tenant |
tenant.Valid("acme-corp") → true. Wraps the request context: ctx = tenant.WithContext(ctx, "acme-corp"). |
| 3 | httpapi/v1/power.go |
dr.d.GetPowerState(c.Request.Context(), guid) — signature unchanged, carries the scoped context. |
| 4 | usecase/devices/power.go |
uc.deviceInTenant(c, guid). |
| 5 | usecase/devices/tenant.go |
tenant.FromContext(ctx) → "acme-corp"; calls uc.repo.GetByID(ctx, guid, "acme-corp"). |
| 6 | usecase/sqldb/device.go |
WHERE guid = ? AND tenantid = ? → ('4c4c...', 'acme-corp'). |
The dependency is invisible to the compiler. GetPowerState(ctx, guid) does not advertise
that it is tenant-scoped. A future entry point that bypasses the middleware — a background
worker, a CLI path, the CIRA TCP handler — receives FromContext → "" and silently reverts to
unscoped behaviour. Nothing fails to build.
Mitigations:
- The single choke point means there is exactly one line to audit, not 41.
- Step 5 will add
ErrTenantNotResolved, distinguishing "the tenant is the empty string" from "no tenant was ever set", so a missing middleware fails closed with a loud error rather than leaking across tenants. Deferred deliberately: it is a behaviour change, and step 3 is behaviour-neutral.
Step 5 needs the same value in profiles, domains, wificonfigs, ieee8021xconfigs and
ciraconfigs. Defining the key inside usecase/devices would have forced either five mutually
incompatible context keys, or sibling feature packages importing devices purely for a context
helper — feature-to-feature coupling that the layering is meant to prevent.
httpapi/middleware ─┐
httpapi/v1 ─┼─→ internal/tenant (no internal deps)
usecase/devices ─┘
internal/ rather than pkg/: CLAUDE.md describes pkg/ as reusable, no-internal-deps
utilities (db, logger, httpserver). A tenant key is application domain, and nothing
outside this repository should import it.
The middleware likewise moved out of httpapi/v1 into httpapi/middleware, because
router.go mounts it on the /api group — covering /api/v2/* too — and version-neutral
middleware should not live in a version package. Its own package (rather than httpapi
itself) avoids an import cycle in the v1 tests.
Step 3 is behaviour-neutral. With no header, tenant.FromContext returns "" — identical to
today's behaviour, so existing deployments continue working. The tenant filter is only applied
when a non-empty x-tenant-id is present.
This design preserves the public API surface while moving the security decision to the query boundary. No management method signature changes, no controller-specific field plumbing, and no EMBEDDED dependence on a tenant parameter in the service interface.
The Explorer HTTP handler now passes tenantIDFromHeader(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.
Added GetByGUID to the device repository and feature, with implementations in both SQL and
MongoDB packages. CIRA authentication now:
- Resolves the device by GUID without a tenant predicate.
- Verifies the MPS username and password.
- Learns the tenant from the authenticated device row.
- Stores that tenant on the CIRA connection entry.
- Rejects a REST caller whose tenant does not own the connection with
401.
The relay is outside the /api group and a browser WebSocket cannot send x-tenant-id.
Tenant scope is therefore established when the redirection JWT is created and consumed.
GET /api/v1/authorize/redirection/{id} first resolves the device using the REST tenant
context. It then adds the device tenant to the short-lived JWT using the shared
tenant.TenantIDClaim claim name. The WebSocket relay validates the JWT signature and device
binding, validates the tenant claim with the same tenant rule used by REST, and stores the
tenant in the request context before calling Redirect.
The existing devices.Feature.Redirect interface remains unchanged. Its implementation uses
the context-scoped device lookup, so the repository applies the tenant predicate before any
redirection connection is created.
An invalid tenant claim is rejected with 403 Forbidden, and the relay is never upgraded or
redirected. A token without a tenant claim currently maps to the empty default tenant for
backwards compatibility with existing tokens; introducing a distinct ErrTenantNotResolved
guard remains a separate hardening change because it would alter non-HTTP/default-tenant
behavior.
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. - Header/body conflict
400behavior. - Malformed tenant values and the 64/65-character boundary.
The patching script used to produce the collections was temporary scaffolding and was deleted; only the JSON artifacts are retained.