Skip to content

console tenancy design

Pola, Sudhir edited this page Aug 27, 2026 · 1 revision

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.

1 High-Level Design Decisions

  1. 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 from A-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.
  2. REST propagation: protected REST APIs read the tenant from the x-tenant-id header, 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.
  3. 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.
  4. WebSocket tenant source: KVM, SOL, and IDE-R use a relay outside the /api group, 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.
  5. 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.

2 Detailed implementation by design decision

The high-level decisions above are expanded below. Each subsection describes the corresponding implementation.

2.1 Design Decision 1 — Tenant identifier format and validation

2.1.1 Requirement 1 — Validate tenant input

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.

2.1.2 Requirement 2 — Reject header/body tenant conflicts

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.

2.2 Design Decision 2 — REST propagation

2.2.1 Requirement 3 — Scope tenant-aware CRUD APIs

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.

2.2.2 Requirement 4 — Scope AMT and device-management APIs

2.2.2.1 Problem

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.

2.2.2.2 Options considered

Two design options were considered:

  1. Adding tenantID string to all 41 device-management interface methods, approximately 45 handlers, and roughly 330 test expectations, (or)

  2. 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.

2.2.2.3 Chosen design (option 2)

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.

2.2.2.4 Worked example — GET /api/v1/amt/power/state/{guid}

Request:

GET /api/v1/amt/power/state/4c4c4544-0043-4810-8053-b8c04f595931 HTTP/1.1
Authorization: Bearer <jwt>
x-tenant-id: acme-corp

Flow:

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').
2.2.2.5 Accepted downside

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:

  1. The single choke point means there is exactly one line to audit, not 41.
  2. 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.
2.2.2.6 Why internal/tenant and not usecase/devices

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.

2.2.2.7 Backwards compatibility

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.

2.2.3 Requirement 6 — Scope AMT Explorer

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.

2.3 Design Decision 3 — Resolve CIRA devices without a tenant header

2.3.1 Requirement 5 — Make CIRA authentication tenant-aware

Added GetByGUID to the device repository and feature, with implementations in both SQL and MongoDB packages. CIRA authentication now:

  1. Resolves the device by GUID without a tenant predicate.
  2. Verifies the MPS username and password.
  3. Learns the tenant from the authenticated device row.
  4. Stores that tenant on the CIRA connection entry.
  5. Rejects a REST caller whose tenant does not own the connection with 401.

2.4 Design Decision 4 — Derive WebSocket tenancy from the redirection JWT

2.4.1 Requirement 8 — WebSocket relay tenant context

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.

2.5 Design Decision 5 — Keep the API contract and test artifacts aligned

2.5.1 Requirement 6 — Update OpenAPI

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.

2.5.2 Requirement 7 — Update Postman collections

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 404 behavior.
  • Header/body conflict 400 behavior.
  • 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.

Clone this wiki locally