blob: 52cf3891f129d0a2ce0ee9815ca0380f1d503eea [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
"""Externally authorized, create-only OPS tenant provisioning."""
import re
import secrets
import string
import uuid
from openserverless.common.validation import is_valid_username
from .kube_client import ProvisioningError
from .namespace_guard import NamespaceGuard
from .routes import route_context, tenant_routes
SECRET_NAME = "openserverless-user-provisioning-token"
SERVICE_NAMES = ("redis", "mongodb", "postgres", "seaweedfs", "minio", "milvus")
# Infrastructure identities must not be provisioned as ordinary tenants.
RESERVED_USERS = {"openserverless", "whisk", "system", "guest", "default", "controller", "invoker"}
def bearer_token(header):
if not isinstance(header, str) or len(header) > 8192:
raise ProvisioningError("Missing or invalid provisioning token", 401)
parts = header.split()
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].isascii():
raise ProvisioningError("Missing or invalid provisioning token", 401)
return parts[1].encode("ascii")
def random_secret(length=32):
return "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(length))
class UserProvisioningService:
def __init__(self, kube):
self.kube = kube
def validate(self, body):
if not isinstance(body, dict) or set(body) - {"user", "password", "email", "services"}:
raise ProvisioningError("Invalid user provisioning payload", 400)
for field in ("user", "password", "email"):
value = body.get(field)
if not isinstance(value, str) or not value.strip() or any(ord(c) < 32 or ord(c) == 127 for c in value):
raise ProvisioningError(f"Invalid or missing {field}", 400)
user, email = body["user"], body["email"]
if not is_valid_username(user) or user in RESERVED_USERS:
raise ProvisioningError("Invalid or reserved user name", 400)
# The operator embeds email in its JSON metadata template. Restrict to
# an ordinary ASCII dot-atom address (no quoted local part or escapes).
local, separator, domain = email.rpartition("@")
local_atom = r"[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+"
domain_label = r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
if (len(email) > 254 or not separator or len(local) > 64
or not re.fullmatch(local_atom + r"(?:\." + local_atom + r")*", local)
or not re.fullmatch(domain_label + r"(?:\." + domain_label + r")+", domain)):
raise ProvisioningError("Invalid email", 400)
# CouchDB uses bcrypt; do not silently truncate UTF-8 passwords.
try:
encoded_password = body["password"].encode("utf-8")
email.encode("utf-8")
except UnicodeError:
raise ProvisioningError("Invalid UTF-8 field", 400) from None
if len(encoded_password) > 72:
raise ProvisioningError("Password exceeds 72 UTF-8 bytes", 400)
selection = body.get("services", "all")
if not isinstance(selection, str):
raise ProvisioningError("Invalid services selection", 400)
selected = [item.strip() for item in selection.split(",")]
if (not all(selected) or len(set(selected)) != len(selected)
or ("all" in selected and len(selected) != 1)
or any(item not in SERVICE_NAMES + ("all",) for item in selected)):
raise ProvisioningError("Invalid services selection", 400)
return selected
def provision(self, body):
selection = self.validate(body)
user = body["user"]
if self.kube.existing_user(user) is not None:
raise ProvisioningError("User already exists", 409)
cluster = self.kube.cluster()
components = cluster.get("spec", {}).get("components")
if not isinstance(components, dict) or any(
not isinstance(components.get(name, False), bool) for name in SERVICE_NAMES
):
raise ProvisioningError("Cluster service configuration unavailable")
available = [name for name in SERVICE_NAMES if components.get(name) is True]
if "seaweedfs" in available and "minio" in available:
raise ProvisioningError("Ambiguous object-storage configuration")
services = available if selection == ["all"] else [name for name in SERVICE_NAMES if name in selection]
if any(name not in available for name in services):
raise ProvisioningError("Requested service is not enabled in this cluster", 400)
# Match the backend identifier limits before submitting an unusable CR.
# FerretDB appends "_ferretdb" to PostgreSQL role/database identifiers.
if "mongodb" in services and len(user) > 54:
raise ProvisioningError("MongoDB users must not exceed 54 characters", 400)
if any(name in services for name in ("seaweedfs", "minio")) and len(user) > 58:
raise ProvisioningError("Object-storage users must not exceed 58 characters", 400)
context = route_context(self.kube.apihost_ingress())
streamer = self.kube.streamer_service() is not None
# Validate hostname and route construction before creating anything.
tenant_routes(user, context, services, streamer, "preflight")
NamespaceGuard().assert_unused(user)
spec = {"namespace": user, "email": body["email"], "password": body["password"],
"auth": f"{uuid.uuid4()}:{random_secret(64)}"}
for name in ("redis", "mongodb", "postgres", "milvus"):
if name in services:
spec[name] = {"enabled": True, "password": random_secret(),
"prefix" if name == "redis" else "database": user}
if "seaweedfs" in services or "minio" in services:
spec["object-storage"] = {"password": random_secret(40), "quota": "auto",
"data": {"enabled": True, "bucket": f"{user}-data"},
"route": {"enabled": True, "bucket": f"{user}-web"}}
resource = {"apiVersion": "openserverless.org/v1", "kind": "WhiskUser",
"metadata": {"name": user, "namespace": "openserverless"}, "spec": spec}
try:
created = self.kube.create_user(resource)
except ProvisioningError as exc:
if exc.status == 409:
raise ProvisioningError("User already exists", 409) from None
raise
uid = created.get("metadata", {}).get("uid")
if not isinstance(uid, str) or not uid:
raise ProvisioningError("User creation could not be confirmed; administrator review required")
try:
for route in tenant_routes(user, context, services, streamer, uid):
self.kube.create_route(route)
except ProvisioningError:
# Do not roll back a tenant which the operator may already be using.
raise ProvisioningError("User created but route setup failed; administrator review required") from None
return {"status": "ok", "message": "User provisioning accepted", "user": user,
"provisioning_status": "pending", "services": services}