diff --git a/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml b/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml index 6380ee41..ed63f69f 100644 --- a/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml +++ b/carvel-packages/installer/bundle/config/ytt/_ytt_lib/packages/educates/_ytt_lib/lookup-service/upstream/crd-clientconfig.yaml @@ -33,6 +33,8 @@ spec: password: type: string minLength: 8 + user: + type: string roles: type: array items: diff --git a/lookup-service/service/caches/clients.py b/lookup-service/service/caches/clients.py index f6f58624..64eb6ca1 100644 --- a/lookup-service/service/caches/clients.py +++ b/lookup-service/service/caches/clients.py @@ -11,19 +11,32 @@ class ClientConfig: name: str uid: str + issue: int password: str + user: str tenants: List[str] roles: List[str] + @property + def identity(self) -> str: + """Return the identity of the client.""" + + return f"client@educates:{self.uid}#{self.issue}" + + def revoke_tokens(self) -> None: + """Revoke all tokens issued to the client.""" + + self.issue += 1 + def check_password(self, password: str) -> bool: """Checks the password provided against the client's password.""" return self.password == password - def validate_identity(self, uid: str) -> bool: + def validate_identity(self, identity: str) -> bool: """Validate the identity provided against the client's identity.""" - return self.uid == uid + return self.identity == identity def has_required_role(self, *roles: str) -> Set: """Check if the client has any of the roles provided. We return back a diff --git a/lookup-service/service/caches/clusters.py b/lookup-service/service/caches/clusters.py index 8ae031a9..1dfa11ac 100644 --- a/lookup-service/service/caches/clusters.py +++ b/lookup-service/service/caches/clusters.py @@ -14,12 +14,12 @@ class ClusterConfig: name: str uid: str - labels: Dict[str, str] + labels: List[Dict[str, str]] kubeconfig: Dict[str, Any] portals: Dict[str, "TrainingPortal"] def __init__( - self, name: str, uid: str, labels: Dict[str, str], kubeconfig: Dict[str, Any] + self, name: str, uid: str, labels: List[Dict[str, str]], kubeconfig: Dict[str, Any] ): self.name = name self.uid = uid diff --git a/lookup-service/service/caches/databases.py b/lookup-service/service/caches/databases.py index cab754c3..2dd37688 100644 --- a/lookup-service/service/caches/databases.py +++ b/lookup-service/service/caches/databases.py @@ -42,7 +42,7 @@ def get_client(self, name: str) -> "ClientConfig": return self.clients.get(name) def authenticate_client(self, name: str, password: str) -> str | None: - """Validate a client's credentials. Returning the uid of the client if + """Validate a client's credentials. Returning the the client if the credentials are valid.""" client = self.get_client(name) @@ -51,7 +51,7 @@ def authenticate_client(self, name: str, password: str) -> str | None: return if client.check_password(password): - return client.uid + return client @dataclass diff --git a/lookup-service/service/caches/environments.py b/lookup-service/service/caches/environments.py index f51be742..929be22b 100644 --- a/lookup-service/service/caches/environments.py +++ b/lookup-service/service/caches/environments.py @@ -26,7 +26,7 @@ class WorkshopEnvironment: workshop: str title: str description: str - labels: Dict[str, str] + labels: List[Dict[str, str]] capacity: int reserved: int allocated: int @@ -43,7 +43,7 @@ def __init__( workshop: str, title: str, description: str, - labels: Dict[str, str], + labels: List[Dict[str, str]], capacity: int, reserved: int, allocated: int, diff --git a/lookup-service/service/caches/portals.py b/lookup-service/service/caches/portals.py index 577069b4..675dce1e 100644 --- a/lookup-service/service/caches/portals.py +++ b/lookup-service/service/caches/portals.py @@ -35,7 +35,7 @@ class TrainingPortal: name: str uid: str generation: int - labels: Dict[Tuple[str, str], str] + labels: List[Dict[str, str]] url: str credentials: PortalCredentials phase: str @@ -49,7 +49,7 @@ def __init__( name: str, uid: str, generation: int, - labels: Dict[str, str], + labels: List[Dict[str, str]], url: str, credentials: PortalCredentials, phase: str, diff --git a/lookup-service/service/handlers/clients.py b/lookup-service/service/handlers/clients.py index 31bb4986..8201ab04 100644 --- a/lookup-service/service/handlers/clients.py +++ b/lookup-service/service/handlers/clients.py @@ -26,6 +26,7 @@ def clientconfigs_update( client_uid = xgetattr(meta, "uid") client_password = xgetattr(spec, "client.password") + client_user = xgetattr(spec, "user") client_tenants = xgetattr(spec, "tenants", []) client_roles = xgetattr(spec, "roles", []) @@ -42,7 +43,9 @@ def clientconfigs_update( ClientConfig( name=client_name, uid=client_uid, + issue=1, password=client_password, + user=client_user, tenants=client_tenants, roles=client_roles, ) diff --git a/lookup-service/service/handlers/clusters.py b/lookup-service/service/handlers/clusters.py index e2ff556c..c9cdebd8 100644 --- a/lookup-service/service/handlers/clusters.py +++ b/lookup-service/service/handlers/clusters.py @@ -152,7 +152,7 @@ def clusterconfigs_update( ClusterConfig( name=name, uid=uid, - labels=xgetattr(spec, "labels", {}), + labels=xgetattr(spec, "labels", []), kubeconfig=kubeconfig, ) ) @@ -164,7 +164,7 @@ def clusterconfigs_update( generation, ) - cluster_config.labels = xgetattr(spec, "labels", {}) + cluster_config.labels = xgetattr(spec, "labels", []) cluster_config.kubeconfig = kubeconfig @@ -250,7 +250,7 @@ async def trainingportals_event(event: kopf.RawEvent, **_): name=portal_name, uid=portal_uid, generation=xgetattr(metadata, "generation"), - labels=xgetattr(spec, "portal.labels", {}), + labels=xgetattr(spec, "portal.labels", []), url=xgetattr(status, "educates.url"), phase=xgetattr(status, "educates.phase"), credentials=credentials, @@ -271,7 +271,7 @@ async def trainingportals_event(event: kopf.RawEvent, **_): portal_state.uid = portal_uid portal_state.generation = xgetattr(metadata, "generation") - portal_state.labels = xgetattr(spec, "portal.labels", {}) + portal_state.labels = xgetattr(spec, "portal.labels", []) portal_state.url = xgetattr(status, "educates.url") portal_state.phase = xgetattr(status, "educates.phase") portal_state.credentials = credentials @@ -358,7 +358,7 @@ async def workshopenvironments_event(event: kopf.RawEvent, **_): name=portal_name, uid=portal_uid, generation=0, - labels={}, + labels=[], url="", phase="Unknown", credentials=PortalCredentials( @@ -393,7 +393,7 @@ async def workshopenvironments_event(event: kopf.RawEvent, **_): workshop=workshop_name, title=xgetattr(workshop_spec, "title"), description=xgetattr(workshop_spec, "description"), - labels=xgetattr(workshop_spec, "labels", {}), + labels=xgetattr(workshop_spec, "labels", []), capacity=xgetattr(status, "educates.capacity", 0), reserved=xgetattr(status, "educates.reserved", 0), allocated=0, @@ -416,7 +416,7 @@ async def workshopenvironments_event(event: kopf.RawEvent, **_): environment_state.description = xgetattr( workshop_spec, "description" ) - environment_state.labels = xgetattr(workshop_spec, "labels", {}) + environment_state.labels = xgetattr(workshop_spec, "labels", []) environment_state.phase = xgetattr(status, "educates.phase") @@ -442,6 +442,7 @@ async def workshopsessions_event(event: kopf.RawEvent, **_): body = xgetattr(event, "object", {}) metadata = xgetattr(body, "metadata", {}) + spec = xgetattr(body, "spec", {}) status = xgetattr(body, "status", {}) portal_name = xgetattr(metadata, "labels", {}).get( @@ -458,6 +459,8 @@ async def workshopsessions_event(event: kopf.RawEvent, **_): "training.educates.dev/environment.uid" ) + workshop_name = xgetattr(spec, "workshop.name") + session_name = xgetattr(metadata, "name") with synchronized(self.cluster_config): @@ -531,7 +534,7 @@ async def workshopsessions_event(event: kopf.RawEvent, **_): name=portal_name, uid=portal_uid, generation=0, - labels={}, + labels=[], url="", phase="Unknown", credentials=PortalCredentials( @@ -561,10 +564,10 @@ async def workshopsessions_event(event: kopf.RawEvent, **_): name=environment_name, uid=environment_uid, generation=0, - workshop="", + workshop=workshop_name, title="", description="", - labels={}, + labels=[], capacity=0, reserved=0, allocated=0, diff --git a/lookup-service/service/routes/authnz.py b/lookup-service/service/routes/authnz.py index 0de936c8..5e610bb1 100644 --- a/lookup-service/service/routes/authnz.py +++ b/lookup-service/service/routes/authnz.py @@ -8,11 +8,12 @@ from aiohttp import web from ..config import jwt_token_secret +from ..caches.clients import ClientConfig TOKEN_EXPIRATION = 72 # Expiration in hours. -def generate_client_token(username: str, uid: str) -> dict: +def generate_login_response(client: ClientConfig) -> dict: """Generate a JWT token for the client. The token will be set to expire and will need to be renewed. The token will contain the username and the unique identifier for the client.""" @@ -25,7 +26,7 @@ def generate_client_token(username: str, uid: str) -> dict: ) jwt_token = jwt.encode( - {"sub": username, "jti": uid, "exp": expires_at}, + {"sub": client.name, "jti": client.identity, "exp": expires_at}, jwt_token_secret(), algorithm="HS256", ) @@ -77,7 +78,7 @@ async def jwt_token_middleware( except jwt.ExpiredSignatureError: return web.Response(text="JWT token has expired", status=401) except jwt.InvalidTokenError: - return web.Response(text="JWT token is invalid", status=400) + return web.Response(text="JWT token is invalid", status=401) # Store the decoded token in the request object for later use. @@ -152,7 +153,7 @@ async def wrapper(request: web.Request) -> web.Response: return decorator -async def api_login_handler(request: web.Request) -> web.Response: +async def api_auth_login(request: web.Request) -> web.Response: """Login handler for accessing the web application. Validates the username and password provided in the request and returns a JWT token if the credentials are valid.""" @@ -175,22 +176,59 @@ async def api_login_handler(request: web.Request) -> web.Response: service_state = request.app["service_state"] client_database = service_state.client_database - uid = client_database.authenticate_client(username, password) + client = client_database.authenticate_client(username, password) - if not uid: + if not client: return web.Response(text="Invalid username/password", status=401) # Generate a JWT token for the user and return it. The response is # bundle with the token type and expiration time so they can be used # by the client without needing to parse the actual JWT token. - token = generate_client_token(username, uid) + token = generate_login_response(client) return web.json_response(token) +async def api_auth_logout(request: web.Request) -> web.Response: + """Logout handler for the web application. The client will be logged out + and the JWT token will be invalidated.""" + + # Check if the decoded JWT token is present in the request object. + + if "jwt_token" not in request: + return web.Response(text="JWT token not supplied", status=400) + + decoded_token = request["jwt_token"] + + # Check the client database for the client by the name of the client + # taken from the JWT token subject. Then check if the identity of the + # client is still the same as the one recorded in the JWT token. + + service_state = request.app["service_state"] + client_database = service_state.client_database + + client = client_database.get_client(decoded_token["sub"]) + + if not client: + return web.Response(text="Client details not found", status=401) + + if not client.validate_identity(decoded_token["jti"]): + return web.Response(text="Client identity does not match", status=401) + + # Revoke the tokens issued to the client. + + client.revoke_tokens() + + return web.json_response({}) + # Set up the middleware and routes for the authentication and authorization. middlewares = [jwt_token_middleware] -routes = [web.post("/login", api_login_handler)] +routes = [ + web.post("/login", api_auth_login), + web.post("/auth/login", api_auth_login), + web.post("/auth/logout", api_auth_logout), + web.get("/auth/verify", login_required(lambda r: web.json_response({}))), +] diff --git a/lookup-service/service/routes/clients.py b/lookup-service/service/routes/clients.py index 86d5f9e1..337234a5 100644 --- a/lookup-service/service/routes/clients.py +++ b/lookup-service/service/routes/clients.py @@ -24,19 +24,18 @@ async def api_get_v1_clients(request: web.Request) -> web.Response: @login_required -@roles_accepted("admin") +@roles_accepted("admin", "tenant") async def api_get_v1_clients_details(request: web.Request) -> web.Response: """Returns details for the specified client.""" - client_name = request.match_info["client"] - - service_state = request.app["service_state"] - client_database = service_state.client_database + client = request["remote_client"] + client_roles = request["client_roles"] - client = client_database.get_client(client_name) + client_name = request.match_info["client"] - if not client: - return web.Response(text="Client not available", status=404) + if "tenant" in client_roles: + if client.name != client_name: + return web.Response(text="Client access not permitted", status=403) details = { "name": client.name, diff --git a/lookup-service/service/routes/workshops.py b/lookup-service/service/routes/workshops.py index 41e52d08..4f47dddf 100644 --- a/lookup-service/service/routes/workshops.py +++ b/lookup-service/service/routes/workshops.py @@ -24,13 +24,13 @@ async def api_get_v1_workshops(request: web.Request) -> web.Response: tenant_name = request.query.get("tenant") - client_name = request["client_name"] + client = request["remote_client"] client_roles = request["client_roles"] if "tenant" in client_roles: if not tenant_name: logger.warning( - "Missing tenant name in request from client %r.", client_name + "Missing tenant name in request from client %r.", client.name ) return web.Response(text="Missing tenant name", status=400) @@ -86,14 +86,14 @@ async def api_post_v1_workshops(request: web.Request) -> web.Response: data = await request.json() - client_name = request["client_name"] + client = request["remote_client"] tenant_name = data.get("tenantName") # TODO: Need to see how can use the action ID supplied by the client. At the # moment we just log it. - user_id = data.get("clientUserId") or "" + user_id = client.user or data.get("clientUserId") or "" action_id = data.get("clientActionId") or "" # pylint: disable=unused-variable index_url = data.get("clientIndexUrl") or "" @@ -102,7 +102,7 @@ async def api_post_v1_workshops(request: web.Request) -> web.Response: logger.info( "Workshop request from client %r for tenant %r, workshop %r, user %r, action %r", - client_name, + client.name, tenant_name, workshop_name, user_id, @@ -110,12 +110,12 @@ async def api_post_v1_workshops(request: web.Request) -> web.Response: ) if not tenant_name: - logger.warning("Missing tenant name in request from client %r.", client_name) + logger.warning("Missing tenant name in request from client %r.", client.name) return web.Response(text="Missing tenantName", status=400) if not workshop_name: - logger.warning("Missing workshop name in request from client %r.", client_name) + logger.warning("Missing workshop name in request from client %r.", client.name) return web.Response(text="Missing workshopName", status=400) @@ -125,7 +125,7 @@ async def api_post_v1_workshops(request: web.Request) -> web.Response: if not client.allowed_access_to_tenant(tenant_name): logger.warning( - "Client %r not allowed access to tenant %r", client_name, tenant_name + "Client %r not allowed access to tenant %r", client.name, tenant_name ) return web.Response(text="Client not allowed access to tenant", status=403) @@ -160,7 +160,7 @@ async def api_post_v1_workshops(request: web.Request) -> web.Response: logger.warning( "Workshop %s requested by client %r not available to tenant %r", workshop_name, - client_name, + client.name, tenant_name, ) @@ -197,7 +197,7 @@ async def api_post_v1_workshops(request: web.Request) -> web.Response: logger.warning( "Workshop %r requested by client %r not available", workshop_name, - client_name, + client.name, ) return web.Response(text="Workshop not available", status=503) @@ -222,7 +222,7 @@ async def api_post_v1_workshops(request: web.Request) -> web.Response: # creating a workshop session. logger.warning( - "Workshop %r requested by client %r not available", workshop_name, client_name + "Workshop %r requested by client %r not available", workshop_name, client.name ) return web.Response(text="Workshop not available", status=503)