Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@
* Its own type, not the custom resource itself -- {@code Tenant} carries a finalizer,
* {@code resourceVersion}, and other managed fields that are the operator's business, not an
* API consumer's, and would change shape with every CRD revision if reused directly here.
*
* <p>{@code redirectUris} is the one field here that is not merely informational: it names the
* two URIs an administrator still has to register with the identity provider before anyone can
* sign in to that tenant's own application instance. The operator cannot register them, and a
* missing registration fails at sign-in with {@code AADSTS50011} and leaves nothing in the
* cluster's logs -- so it has to reach the console, where the person who created the tenant is
* standing. Empty for a tenant with no instance, never null.
*/
@Serdeable
public record TenantResponse(
Expand All @@ -38,6 +45,7 @@ public record TenantResponse(
String namespace,
String objectStoreUser,
Long storageUsedBytes,
List<String> redirectUris,
List<ConditionResponse> conditions) {

public static TenantResponse from(Tenant tenant) {
Expand All @@ -52,6 +60,7 @@ public static TenantResponse from(Tenant tenant) {
status.getNamespace(),
status.getObjectStoreUser(),
status.getStorageUsedBytes(),
List.copyOf(status.getRedirectUris()),
status.getConditions().stream().map(ConditionResponse::from).toList());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,41 @@ void updateRejectsAnUnknownTenant() {
var request = new UpdateTenantRequest("500Gi", null, null, null);
assertThrows(NotFoundException.class, () -> controller.update(platformAdmin(), "does-not-exist", request));
}

/**
* The operator reports the redirect URIs a tenant's own application instance needs, because
* it cannot register them with the identity provider itself. They have to reach the console,
* or the person who just created a tenant walks away without being told what remains -- and
* the failure they eventually hit (AADSTS50011 at sign-in) leaves no trace in this cluster.
*/
@Test
void listReportsTheRedirectUrisTheOperatorPublished() {
Tenant tenant = new Tenant();
tenant.getMetadata().setName("acme");
tenant.getStatus()
.setRedirectUris(List.of(
"https://apus.example.dev/t/acme/auth/callback",
"https://apus.example.dev/t/acme/auth/silent-renew"));
repository.put(tenant);

var response = controller.list(platformAdmin());

assertEquals(
List.of(
"https://apus.example.dev/t/acme/auth/callback",
"https://apus.example.dev/t/acme/auth/silent-renew"),
response.body().get(0).redirectUris());
}

/** A tenant with no application instance reports an empty list, never null. */
@Test
void listReportsNoRedirectUrisForATenantWithoutAnInstance() {
Tenant tenant = new Tenant();
tenant.getMetadata().setName("acme");
repository.put(tenant);

var response = controller.list(platformAdmin());

assertTrue(response.body().get(0).redirectUris().isEmpty());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ spec:
type: string
pushTokenSecret:
type: string
redirectUris:
items:
type: string
type: array
storageUsedBytes:
type: integer
type: object
Expand Down
24 changes: 24 additions & 0 deletions deploy/charts/apus-operator/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,30 @@ The apus-operator is installed.
{{- end }}
{{- end }}

{{- if .Values.tenantUi.host }}

IMPORTANT: every tenant needs two redirect URIs registered by hand.

tenantUi.host is set, so each Tenant gets its own instance of the tenant application at
https://{{ .Values.tenantUi.host }}/t/<tenant>/. The operator cannot register that instance's
redirect URIs with your identity provider -- doing so needs application permissions on the
app registration that this chart does not ask for and nobody has granted.

Until they are registered, signing in to a tenant's instance fails at the identity provider
(Entra reports AADSTS50011) and nothing appears in this cluster's logs at all. Read the two
URIs for a tenant off its status:

kubectl get tenant <name> -o jsonpath='{.status.redirectUris}'

They are always these two:

https://{{ .Values.tenantUi.host }}/t/<tenant>/auth/callback
https://{{ .Values.tenantUi.host }}/t/<tenant>/auth/silent-renew

A wildcard is not a shortcut here: Entra strips the query string when a wildcard redirect URI
matches, and the authorization code lives in that query string.
{{- end }}

This chart only installs the operator: the CRDs, the controller and its RBAC. It has no
user interface. The `apus-platform` chart installs the REST API and the dashboard that let
you manage tenants, worlds and renders without talking to the Kubernetes API directly.
16 changes: 16 additions & 0 deletions deploy/charts/apus-operator/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ spec:
value: {{ .Values.bundles.s3Region | quote }}
- name: APUS_BUNDLE_CREDENTIALS_SECRET
value: {{ .Values.bundles.credentialsSecret | quote }}
# An empty host switches the per-tenant application instance off entirely, which is
# the default -- the operator checks this one value and creates nothing at all.
- name: APUS_TENANT_UI_HOST
value: {{ .Values.tenantUi.host | quote }}
- name: APUS_TENANT_UI_IMAGE
value: {{ include "apus-operator.image" (dict "image" .Values.tenantUi.image "ctx" .) | quote }}
- name: APUS_TENANT_UI_INGRESS_CLASS
value: {{ .Values.tenantUi.ingressClassName | quote }}
- name: APUS_TENANT_UI_API_BASE_URL
value: {{ .Values.tenantUi.apiBaseUrl | quote }}
- name: APUS_TENANT_UI_OIDC_ISSUER
value: {{ .Values.tenantUi.oidc.issuer | quote }}
- name: APUS_TENANT_UI_OIDC_CLIENT_ID
value: {{ .Values.tenantUi.oidc.clientId | quote }}
- name: APUS_TENANT_UI_OIDC_SCOPE
value: {{ .Values.tenantUi.oidc.scope | quote }}
{{- if .Values.otel.endpoint }}
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: {{ .Values.otel.endpoint | quote }}
Expand Down
28 changes: 28 additions & 0 deletions deploy/charts/apus-operator/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,34 @@
}
}
},
"tenantUi": {
"type": "object",
"description": "One instance of the tenant application per tenant. Not required: an empty host disables it, which is the default.",
"properties": {
"host": { "type": "string" },
"image": {
"type": "object",
"properties": {
"repository": { "type": "string", "minLength": 1 },
"tag": { "type": "string" }
}
},
"ingressClassName": { "type": "string", "minLength": 1 },
"apiBaseUrl": {
"type": "string",
"description": "Origin only, with no /api suffix -- the typed client already asks for /api paths, so a suffix here produces /api/api/tenants and a bare 403.",
"not": { "pattern": "/api/?$" }
},
"oidc": {
"type": "object",
"properties": {
"issuer": { "type": "string" },
"clientId": { "type": "string" },
"scope": { "type": "string" }
}
}
}
},
"replicaCount": { "type": "integer", "minimum": 1, "maximum": 1 }
}
}
37 changes: 37 additions & 0 deletions deploy/charts/apus-operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,43 @@ bundles:
s3Region: us-east-1
credentialsSecret: apus-bundle-credentials

# One instance of the tenant application per tenant, served at https://<host>/t/<tenant>/.
# The operator creates a Deployment, a Service and an Ingress in each tenant's own namespace
# -- the Ingress has to live there, since an Ingress may only reference a Service in its own
# namespace.
#
# Off by default: an empty host disables the feature entirely. An instance with no host would
# have nothing to serve it, and a Deployment nobody can reach costs a pod per tenant.
#
# One image serves every tenant. NUXT_APP_BASE_URL moves the served prefix at runtime, so a
# tenant instance differs from the platform chart's own `ui` in exactly one variable.
#
# Registering the two redirect URIs each instance needs is a manual step -- the operator has no
# permission on the app registration. They are reported on Tenant.status.redirectUris; see the
# notes printed after install.
tenantUi:
# e.g. apus.example.dev -- the same host the apus-platform ingress serves.
host: ""
image:
repository: harbor.onelitefeather.dev/apus/ui
# Empty falls back to .Chart.AppVersion, like every other image here.
tag: ""
# Must match the apus-platform ingress's class: both serve paths on the same host.
ingressClassName: nginx
# Handed to every instance as NUXT_PUBLIC_*. None is a secret -- this is a public OIDC client
# and all of it ends up in the served HTML by design. Every value is identical between
# tenants; only the base path differs, and the operator computes that.
#
# The origin only, with no /api suffix: the typed client already asks for paths beginning
# with /api, so a suffix here produces /api/api/tenants and a bare 403.
apiBaseUrl: ""
oidc:
issuer: ""
clientId: ""
# Entra-specific and not optional there: asking for only `openid profile email` returns a
# token addressed to Microsoft Graph, which the API rejects.
scope: ""

metrics:
enabled: true
port: 8080
Expand Down
Loading
Loading