diff --git a/docs/deployment/authentication/azuread-auth.mdx b/docs/deployment/authentication/azuread-auth.mdx new file mode 100644 index 000000000..2ee4fd365 --- /dev/null +++ b/docs/deployment/authentication/azuread-auth.mdx @@ -0,0 +1,202 @@ +--- +title: "Azure AD Authentication" +--- + + +This feature is a part of Keep Enterprise. + +Talk to us to get access: https://www.keephq.dev/meet-keep + + +Keep supports enterprise authentication through Azure Active Directory (Azure AD), enabling organizations to use their existing Microsoft identity platform for secure access management. + +## When to Use + +- **Microsoft Environment:** If your organization uses Microsoft 365 or Azure services, Azure AD integration provides seamless authentication. +- **Enterprise SSO:** Leverage Azure AD's Single Sign-On capabilities for unified access management. + +## Setup Instructions (on Azure AD) + +### Creating an Azure AD Application + +1. Sign in to the [Azure Portal](https://portal.azure.com) +2. Navigate to **Microsoft Entra ID** > **App registrations** > **New registration** + + + Azure AD App Registration + + +3. Configure the application: + - Name: "Keep" + +Note that we are using "Register an application to integrate with Microsoft Entra ID (App you're developing)" since you're self-hosting Keep and need direct control over the authentication flow and permissions for your specific instance - unlike the cloud/managed version where Keep's team has already configured a centralized application registration. + + + Azure AD App Registration + + +4. Configure the application (continue) +- Supported account types: "Single tenant" + + +We recommend using "Single tenant" for enhanced security as it restricts access to users within your organization only. While multi-tenant configuration is possible, it would allow users from any Azure AD directory to access your Keep instance, which could pose security risks unless you have specific cross-organization requirements. + + + - Redirect URI: "Web" + your redirect URI + + +We use "Web" platform instead of "Single Page Application (SPA)" because Keep's backend handles the authentication flow using client credentials/secrets, which is more secure than the implicit flow used in SPAs. This prevents exposure of tokens in the browser and provides stronger security through server-side token validation and refresh token handling. + + + +For localhost, the redirect would be http://localhost:3000/api/auth/callback/azure-ad + +For production, it should be something like http://your_keep_frontend_domain/api/auth/callback/azure-ad + + + + + Azure AD App Registration + + +5. Finally, click "register" + +### Configure Authentication +After we created the application, let's configure the authentication. + +1. Go to "App Registrations" -> "All applications" + + + Azure AD Authentication Configuration + + + +2. Click on your application -> "Add a certificate or secret" + + + Azure AD Authentication Configuration + + + +3. Click on "New client secret" and give it a name + + + Azure AD Authentication Configuration + + +4. Keep the "Value", we will use it soon as `KEEP_AZUREAD_CLIENT_SECRET` + + + Azure AD Authentication Configuration + + +### Configure Groups + +Keep maps Azure AD groups to roles with two default groups: +1. Admin Group (read + write) +2. NOC Group (read only) + +To create those groups, go to Groups -> All groups and create two groups: + + + Azure AD Authentication Configuration + + +Keep the Object id of these groups and use it as `KEEP_AZUREAD_ADMIN_GROUP_ID` and `KEEP_AZUREAD_NOC_GROUP_ID`. + +### Configure Group Claims + +1. Navigate to **Token configuration** + + + Azure AD Authentication Configuration + + + +2. Add groups claim: + - Select "Security groups" and "Groups assigned to the application" + - Choose "Group ID" as the claim value + + + Azure AD Authentication Configuration + + + + + Azure AD Authentication Configuration + + +### Configure Application Scopes + +1. Go to "Expose an API" and click on "Add a scope" + + + Azure AD Authentication Configuration + + +2. Keep the default Application ID and click "Save and continue" + + + Azure AD Authentication Configuration + + +3. Add "default" as scope name, also give a display name and description + + + Azure AD Authentication Configuration + + +3. Finally, click "Add scope" + + + Azure AD Authentication Configuration + + +## Setup Instructions (on Keep) + +After you configured Azure AD you should have the following: +1. Azure AD Tenant ID +2. Azure AD Client ID + +How to get: + + + Azure AD Authentication Configuration + + +3. Azure AD Client Secret [See Configure Authentication](#configure-authentication). +4. Azure AD Group ID's for Admins and NOC (read only) [See Configure Groups](#configure-groups). + + +### Configuration + +#### Frontend + +| Environment Variable | Description | Required | Default Value | +|--------------------|-------------|:---------:|:-------------:| +| AUTH_TYPE | Set to 'AZUREAD' for Azure AD authentication | Yes | - | +| KEEP_AZUREAD_CLIENT_ID | Your Azure AD application (client) ID | Yes | - | +| KEEP_AZUREAD_CLIENT_SECRET | Your client secret | Yes | - | +| KEEP_AZUREAD_TENANT_ID | Your Azure AD tenant ID | Yes | - | +| NEXTAUTH_URL | Your Keep application URL | Yes | - | +| NEXTAUTH_SECRET | Random string for NextAuth.js | Yes | - | + +#### Backend + +| Environment Variable | Description | Required | Default Value | +|--------------------|-------------|:---------:|:-------------:| +| AUTH_TYPE | Set to 'AZUREAD' for Azure AD authentication | Yes | - | +| KEEP_AZUREAD_TENANT_ID | Your Azure AD tenant ID | Yes | - | +| KEEP_AZUREAD_CLIENT_ID | Your Azure AD application (client) ID | Yes | - | +| KEEP_AZUREAD_ADMIN_GROUP_ID | The group ID of Keep Admins (read write) | Yes | - | +| KEEP_AZUREAD_NOC_GROUP_ID | The group ID of Keep NOC (read only) | Yes | - | + +## Features and Limitations + +#### Supported Features +- Single Sign-On (SSO) +- Role-based access control through Azure AD groups +- Multi-factor authentication (when configured in Azure AD) + +#### Limitations +See [Overview](/deployment/authentication/overview) diff --git a/docs/deployment/authentication/overview.mdx b/docs/deployment/authentication/overview.mdx index 3ce52bfda..d3132bc39 100644 --- a/docs/deployment/authentication/overview.mdx +++ b/docs/deployment/authentication/overview.mdx @@ -13,6 +13,7 @@ Keep supports various authentication providers and architectures to accommodate - [**DB**](/deployment/authentication/db-auth) - Simple username/password authentication. Works well for small teams or for dev/stage environments. Users and hashed password are stored on DB. - [**Auth0**](/deployment/authentication/auth0-auth) - Utilize Auth0 for scalable, auth0-based authentication. - [**Keycloak**](/deployment/authentication/keycloak-auth) - Utilize Keycloak for enterprise authentication methods such as SSO/SAML/OIDC, advanced RBAC with custom roles, resource-level permissions, and integration with user directories (LDAP). +- [**AzureAD**](/deployment/authentication/azuread-auth) - Utilize Azure AD for SSO/SAML/OIDC nterprise authentication. Choosing the right authentication strategy depends on your specific use case, security requirements, and deployment environment. You can read more about each authentication provider. @@ -27,6 +28,7 @@ Choosing the right authentication strategy depends on your specific use case, se | **Auth0** | ✅
(Predefiend roles) | ✅ | 🚧 | 🚧 | ✅ | 🚧 | ❌ | **EE** | | **Keycloak** | ✅
(Custom roles) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | **EE** | | **Oauth2Proxy** | ✅
(Predefiend roles) | ✅ | ❌ | ❌ | N/A | N/A | ✅ | **OSS** | +| **Azure AD** | ✅
(Predefiend roles) | ✅ | ❌ | ❌ | By Azure AD | By Azure AD | ✅ | **EE** | ### How To Configure Some authentication providers require additional environment variables. These will be covered in detail on the specific authentication provider pages. @@ -41,5 +43,6 @@ The authentication scheme on Keep is controlled with environment variables both | **Auth0** | `AUTH_TYPE=AUTH0` | `AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET` | | **Keycloak** | `AUTH_TYPE=KEYCLOAK` | `KEYCLOAK_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET` | | **Oauth2Proxy** | `AUTH_TYPE=OAUTH2PROXY` | `OAUTH2_PROXY_USER_HEADER`, `OAUTH2_PROXY_ROLE_HEADER`, `OAUTH2_PROXY_AUTO_CREATE_USER` | +| **AzureAD** | `AUTH_TYPE=AZUREAD` | See [AzureAD Configuration](/deployment/authentication/azuread-auth) | For more details on each authentication strategy, including setup instructions and implications, refer to the respective sections. diff --git a/docs/images/azuread_1.png b/docs/images/azuread_1.png new file mode 100644 index 000000000..6b83bc8cf Binary files /dev/null and b/docs/images/azuread_1.png differ diff --git a/docs/images/azuread_10.png b/docs/images/azuread_10.png new file mode 100644 index 000000000..eb39b08d7 Binary files /dev/null and b/docs/images/azuread_10.png differ diff --git a/docs/images/azuread_11.png b/docs/images/azuread_11.png new file mode 100644 index 000000000..964caf880 Binary files /dev/null and b/docs/images/azuread_11.png differ diff --git a/docs/images/azuread_12.png b/docs/images/azuread_12.png new file mode 100644 index 000000000..dc6758ff7 Binary files /dev/null and b/docs/images/azuread_12.png differ diff --git a/docs/images/azuread_13.png b/docs/images/azuread_13.png new file mode 100644 index 000000000..34ba72167 Binary files /dev/null and b/docs/images/azuread_13.png differ diff --git a/docs/images/azuread_14.png b/docs/images/azuread_14.png new file mode 100644 index 000000000..3d5d0b384 Binary files /dev/null and b/docs/images/azuread_14.png differ diff --git a/docs/images/azuread_15.png b/docs/images/azuread_15.png new file mode 100644 index 000000000..c27483370 Binary files /dev/null and b/docs/images/azuread_15.png differ diff --git a/docs/images/azuread_16.png b/docs/images/azuread_16.png new file mode 100644 index 000000000..2f7dd8977 Binary files /dev/null and b/docs/images/azuread_16.png differ diff --git a/docs/images/azuread_2.png b/docs/images/azuread_2.png new file mode 100644 index 000000000..c57368029 Binary files /dev/null and b/docs/images/azuread_2.png differ diff --git a/docs/images/azuread_3.png b/docs/images/azuread_3.png new file mode 100644 index 000000000..c7466ca36 Binary files /dev/null and b/docs/images/azuread_3.png differ diff --git a/docs/images/azuread_4.png b/docs/images/azuread_4.png new file mode 100644 index 000000000..8d995462b Binary files /dev/null and b/docs/images/azuread_4.png differ diff --git a/docs/images/azuread_5.png b/docs/images/azuread_5.png new file mode 100644 index 000000000..209690421 Binary files /dev/null and b/docs/images/azuread_5.png differ diff --git a/docs/images/azuread_6.png b/docs/images/azuread_6.png new file mode 100644 index 000000000..e5b44ffbc Binary files /dev/null and b/docs/images/azuread_6.png differ diff --git a/docs/images/azuread_7.png b/docs/images/azuread_7.png new file mode 100644 index 000000000..1ea01ed68 Binary files /dev/null and b/docs/images/azuread_7.png differ diff --git a/docs/images/azuread_8.png b/docs/images/azuread_8.png new file mode 100644 index 000000000..6700a9b01 Binary files /dev/null and b/docs/images/azuread_8.png differ diff --git a/docs/images/azuread_9.png b/docs/images/azuread_9.png new file mode 100644 index 000000000..bb497af69 Binary files /dev/null and b/docs/images/azuread_9.png differ diff --git a/docs/mint.json b/docs/mint.json index 8aa958eee..70cbaf4e8 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -64,6 +64,7 @@ "deployment/authentication/no-auth", "deployment/authentication/db-auth", "deployment/authentication/auth0-auth", + "deployment/authentication/azuread-auth", "deployment/authentication/keycloak-auth", "deployment/authentication/oauth2proxy-auth" ] diff --git a/ee/identitymanager/identity_managers/azuread/__init__.py b/ee/identitymanager/identity_managers/azuread/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/ee/identitymanager/identity_managers/azuread/azuread_authverifier.py b/ee/identitymanager/identity_managers/azuread/azuread_authverifier.py new file mode 100644 index 000000000..35f765edc --- /dev/null +++ b/ee/identitymanager/identity_managers/azuread/azuread_authverifier.py @@ -0,0 +1,283 @@ +import hashlib +import logging +import os +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +import jwt +import requests +from fastapi import Depends, HTTPException +from jwt import PyJWK +from jwt.exceptions import ( + ExpiredSignatureError, + InvalidIssuedAtError, + InvalidIssuerError, + InvalidTokenError, + MissingRequiredClaimError, +) + +from keep.api.core.db import create_user, update_user_last_sign_in, user_exists +from keep.identitymanager.authenticatedentity import AuthenticatedEntity +from keep.identitymanager.authverifierbase import AuthVerifierBase, oauth2_scheme +from keep.identitymanager.rbac import Admin as AdminRole +from keep.identitymanager.rbac import Noc as NOCRole +from keep.identitymanager.rbac import get_role_by_role_name + +logger = logging.getLogger(__name__) + + +class AzureADGroupMapper: + """Maps Azure AD groups to Keep roles""" + + def __init__(self): + # Get group IDs from environment variables + self.admin_group_id = os.environ.get("KEEP_AZUREAD_ADMIN_GROUP_ID") + self.noc_group_id = os.environ.get("KEEP_AZUREAD_NOC_GROUP_ID") + + if not all([self.admin_group_id, self.noc_group_id]): + raise Exception( + "Missing KEEP_AZUREAD_ADMIN_GROUP_ID or KEEP_AZUREAD_NOC_GROUP_ID environment variables" + ) + + # Define group to role mapping + self.group_role_mapping = { + self.admin_group_id: AdminRole.get_name(), + self.noc_group_id: NOCRole.get_name(), + } + + def get_role_from_groups(self, groups: List[str]) -> Optional[str]: + """ + Determine Keep role based on Azure AD group membership + Returns highest privilege role if user is in multiple groups + """ + user_roles = set() + for group_id in groups: + if role := self.group_role_mapping.get(group_id): + user_roles.add(role) + + # If user is in admin group, return admin role + if AdminRole.get_name() in user_roles: + return AdminRole.get_name() + # If user is in NOC group, return NOC role + elif NOCRole.get_name() in user_roles: + return NOCRole.get_name() + # No matching groups + return None + + +class AzureADKeysManager: + """Singleton class to manage Azure AD signing keys""" + + _instance = None + _signing_keys: Dict[str, Any] = {} + _last_updated: Optional[datetime] = None + _cache_duration = timedelta(hours=24) + + def __new__(cls): + if cls._instance is None: + cls._instance = super(AzureADKeysManager, cls).__new__(cls) + return cls._instance + + def __init__(self): + if self._last_updated is None: + self.tenant_id = os.environ.get("KEEP_AZUREAD_TENANT_ID") + if not self.tenant_id: + raise Exception("Missing KEEP_AZUREAD_TENANT_ID environment variable") + self.jwks_uri = f"https://login.microsoftonline.com/{self.tenant_id}/discovery/v2.0/keys" + self._refresh_keys() + + def _refresh_keys(self) -> None: + """Fetch signing keys from Azure AD's JWKS endpoint""" + try: + response = requests.get(self.jwks_uri) + response.raise_for_status() + jwks = response.json() + + new_keys = {} + for key in jwks.get("keys", []): + if key.get("use") == "sig": # Only use signing keys + logger.debug("Loading public key from certificate: %s", key) + cert_obj = PyJWK(key, "RS256") + if kid := key.get("kid"): + new_keys[kid] = cert_obj.key + + if new_keys: # Only update if we got valid keys + self._signing_keys = new_keys + self._last_updated = datetime.utcnow() + logger.info("Successfully refreshed Azure AD signing keys") + else: + logger.error("No valid signing keys found in JWKS response") + + except requests.RequestException as e: + logger.error(f"Failed to fetch signing keys: {str(e)}") + if not self._signing_keys: + raise HTTPException( + status_code=500, detail="Unable to verify tokens at this time" + ) + + def get_signing_key(self, kid: str) -> Optional[Any]: + """Get a signing key by its ID, refreshing if necessary""" + now = datetime.utcnow() + + # Refresh keys if they're expired or if we can't find the requested key + if ( + self._last_updated is None + or now - self._last_updated > self._cache_duration + or (kid not in self._signing_keys) + ): + self._refresh_keys() + + return self._signing_keys.get(kid) + + +# Initialize the keys manager globally +azure_keys_manager = AzureADKeysManager() + + +class AzureadAuthVerifier(AuthVerifierBase): + """Handles authentication and authorization for Azure AD""" + + def __init__(self, scopes: list[str] = []) -> None: + super().__init__(scopes) + # Azure AD configurations + self.tenant_id = os.environ.get("KEEP_AZUREAD_TENANT_ID") + self.client_id = os.environ.get("KEEP_AZUREAD_CLIENT_ID") + + if not all([self.tenant_id, self.client_id]): + raise Exception( + "Missing KEEP_AZUREAD_TENANT_ID or KEEP_AZUREAD_CLIENT_ID environment variable" + ) + + self.issuer = f"https://sts.windows.net/{self.tenant_id}/" + self.group_mapper = AzureADGroupMapper() + # Keep track of hashed tokens so we won't update the user on the same token + self.saw_tokens = set() + + def _verify_bearer_token( + self, token: str = Depends(oauth2_scheme) + ) -> AuthenticatedEntity: + """Verify the Azure AD JWT token and extract claims""" + try: + # First decode without verification to get the key id (kid) + unverified_headers = jwt.get_unverified_header(token) + kid = unverified_headers.get("kid") + + if not kid: + raise HTTPException(status_code=401, detail="No key ID in token header") + + # Get the signing key from the global manager + signing_key = azure_keys_manager.get_signing_key(kid) + if not signing_key: + raise HTTPException(status_code=401, detail="Invalid token signing key") + + # Verify and decode the token + options = { + "verify_signature": True, + "verify_aud": False, # We'll validate manually + "verify_iat": True, + "verify_exp": True, + "verify_nbf": True, + "verify_iss": True, + "require": ["exp", "iat", "nbf", "iss", "sub", "appid"], + } + + try: + payload = jwt.decode( + token, + key=signing_key, + algorithms=["RS256"], + issuer=self.issuer, + options=options, + ) + + # Validate the appid claim instead of audience + if payload.get("appid") != self.client_id: + raise HTTPException( + status_code=401, detail="Invalid token application ID" + ) + # validate aud + if payload.get("aud") != f"api://{self.client_id}": + raise HTTPException( + status_code=401, detail="Invalid token audience" + ) + + except ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token has expired") + except InvalidIssuerError: + raise HTTPException(status_code=401, detail="Invalid token issuer") + except (InvalidIssuedAtError, MissingRequiredClaimError): + raise HTTPException( + status_code=401, detail="Token is missing required claims" + ) + except InvalidTokenError as e: + logger.error(f"Token validation failed: {str(e)}") + raise HTTPException(status_code=401, detail="Invalid token") + + # Extract relevant claims + tenant_id = payload.get("tid") + email = ( + payload.get("email") + or payload.get("preferred_username") + or payload.get("unique_name") + ) + + if not all([tenant_id, email]): + raise HTTPException(status_code=401, detail="Missing required claims") + + # Clean up email if it's in the live.com#email@domain.com format + if "#" in email: + email = email.split("#")[1] + + # Get groups from token + groups = payload.get("groups", []) + + # Map groups to role + role_name = self.group_mapper.get_role_from_groups(groups) + if not role_name: + raise HTTPException( + status_code=403, + detail="You are using Azure AD but the user is not a member of any authorized groups. You need to be a member of an authorized group to access Keep.", + ) + + # Validate role has required scopes + role = get_role_by_role_name(role_name) + if not role.has_scopes(self.scopes): + raise HTTPException( + status_code=403, + detail=f"Role {role_name} does not have required permissions", + ) + + # Auto-provision so we can list users + hashed_token = hashlib.sha256(token.encode()).hexdigest() + if hashed_token not in self.saw_tokens and not user_exists( + tenant_id, email + ): + create_user( + tenant_id=tenant_id, username=email, role=role_name, password="" + ) + + if hashed_token not in self.saw_tokens: + update_user_last_sign_in(tenant_id, email) + # Add token to seen tokens + self.saw_tokens.add(hashed_token) + return AuthenticatedEntity(tenant_id, email, None, role_name) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Token validation failed: {str(e)}") + raise HTTPException(status_code=401, detail="Invalid token") + + def _authorize(self, authenticated_entity: AuthenticatedEntity) -> None: + """ + Authorize the authenticated entity against required scopes + """ + if not authenticated_entity.role: + raise HTTPException(status_code=403, detail="No role assigned") + + role = get_role_by_role_name(authenticated_entity.role) + if not role.has_scopes(self.scopes): + raise HTTPException( + status_code=403, + detail="You don't have the required permissions to access this resource", + ) diff --git a/ee/identitymanager/identity_managers/azuread/azuread_identitymanager.py b/ee/identitymanager/identity_managers/azuread/azuread_identitymanager.py new file mode 100644 index 000000000..373c08bb4 --- /dev/null +++ b/ee/identitymanager/identity_managers/azuread/azuread_identitymanager.py @@ -0,0 +1,33 @@ +from ee.identitymanager.identity_managers.azuread.azuread_authverifier import ( + AzureadAuthVerifier, +) +from keep.api.models.user import User +from keep.contextmanager.contextmanager import ContextManager +from keep.identitymanager.identity_managers.db.db_identitymanager import ( + DbIdentityManager, +) +from keep.identitymanager.identitymanager import BaseIdentityManager + + +class AzureadIdentityManager(BaseIdentityManager): + def __init__(self, tenant_id, context_manager: ContextManager, **kwargs): + super().__init__(tenant_id, context_manager, **kwargs) + self.db_identity_manager = DbIdentityManager( + tenant_id, context_manager, **kwargs + ) + + def get_users(self) -> list[User]: + # we keep the azuread users in the db + return self.db_identity_manager.get_users(self.tenant_id) + + def create_user(self, user_email: str, role: str, **kwargs) -> dict: + return None + + def delete_user(self, user_email: str) -> dict: + raise NotImplementedError("AzureadIdentityManager.delete_user") + + def get_auth_verifier(self, scopes) -> AzureadAuthVerifier: + return AzureadAuthVerifier(scopes) + + def update_user(self, user_email: str, update_data: dict) -> User: + raise NotImplementedError("AzureadIdentityManager.update_user") diff --git a/ee/identitymanager/identity_managers/keycloak/keycloak_authverifier.py b/ee/identitymanager/identity_managers/keycloak/keycloak_authverifier.py index 1a2fd7516..d38af7f50 100644 --- a/ee/identitymanager/identity_managers/keycloak/keycloak_authverifier.py +++ b/ee/identitymanager/identity_managers/keycloak/keycloak_authverifier.py @@ -1,5 +1,5 @@ -import os import logging +import os from fastapi import Depends, HTTPException @@ -52,7 +52,9 @@ def _verify_bearer_token( # verify keycloak token try: payload = self.keycloak_client.decode_token(token, validate=True) - except Exception: + except Exception as e: + if "Expired" in str(e): + raise HTTPException(status_code=401, detail="Expired Keycloak token") raise HTTPException(status_code=401, detail="Invalid Keycloak token") tenant_id = payload.get("keep_tenant_id") email = payload.get("preferred_username") @@ -95,6 +97,28 @@ def _authorize(self, authenticated_entity: AuthenticatedEntity) -> None: allowed = self.keycloak_uma.permissions_check( token=authenticated_entity.token, permissions=[permission] ) + if not allowed: + raise HTTPException(status_code=401, detail="Permission check failed") + # secure fallback + except Exception as e: + raise HTTPException( + status_code=401, detail="Permission check failed - " + str(e) + ) + return allowed + + def authorize_resource( + self, resource_type, resource_id, authenticated_entity: AuthenticatedEntity + ) -> None: + # use Keycloak's UMA to authorize + try: + permission = UMAPermission( + resource=resource_id, + ) + allowed = self.keycloak_uma.permissions_check( + token=authenticated_entity.token, permissions=[permission] + ) + if not allowed: + raise HTTPException(status_code=401, detail="Permission check failed") # secure fallback except Exception: raise HTTPException(status_code=401, detail="Permission check failed") diff --git a/ee/identitymanager/identity_managers/keycloak/keycloak_identitymanager.py b/ee/identitymanager/identity_managers/keycloak/keycloak_identitymanager.py index 3c0ab08f1..1646a8d4a 100644 --- a/ee/identitymanager/identity_managers/keycloak/keycloak_identitymanager.py +++ b/ee/identitymanager/identity_managers/keycloak/keycloak_identitymanager.py @@ -9,6 +9,7 @@ from ee.identitymanager.identity_managers.keycloak.keycloak_authverifier import ( KeycloakAuthVerifier, ) +from keep.api.core.db import get_resource_ids_by_resource_type from keep.api.models.user import Group, PermissionEntity, ResourcePermission, Role, User from keep.contextmanager.contextmanager import ContextManager from keep.identitymanager.authenticatedentity import AuthenticatedEntity @@ -26,6 +27,18 @@ class KeycloakIdentityManager(BaseIdentityManager): + + RESOURCES = { + "preset": { + "table": "preset", + "uid": "id", + }, + "incident": { + "table": "incident", + "uid": "id", + }, + } + def __init__(self, tenant_id, context_manager: ContextManager, **kwargs): super().__init__(tenant_id, context_manager, **kwargs) self.server_url = os.environ.get("KEYCLOAK_URL") @@ -55,6 +68,11 @@ def __init__(self, tenant_id, context_manager: ContextManager, **kwargs): self.keep_controlled_keycloak = ( os.environ.get("KEYCLOAK_KEEP_CONTROLLED", "false") == "true" ) + # Does ABAC is enabled + self.abac_enabled = ( + os.environ.get("KEYCLOAK_ABAC_ENABLED", "true") == "true" + ) + except Exception as e: self.logger.error( "Failed to initialize Keycloak Identity Manager: %s", str(e) @@ -89,14 +107,44 @@ def on_start(self, app) -> None: if not isinstance(dep.cache_key[0], KeycloakAuthVerifier): continue scopes = dep.cache_key[0].scopes - dep.cache_key[0].protected_resource = route.path + # this is the KeycloakAuthVerifier dependency :) + methods = list(route.methods) + if len(methods) > 1: + self.logger.warning( + "Keep does not support multiple methods for a single route", + ) + continue + protected_resource = methods[0] + " " + route.path + dep.cache_key[0].protected_resource = protected_resource + break # protected route but without scopes if not scopes: self.logger.warning("Route without scopes: %s", route.path) - self.create_resource(route.path, scopes=scopes, resource_type="keep_route") + self.create_resource( + protected_resource, scopes=scopes, resource_type="keep_route" + ) self.logger.info("Resource created for route: %s", route.path) + + # create resource for each object + if self.abac_enabled: + for resource_type, resource_type_data in self.RESOURCES.items(): + self.logger.info("Creating resource for object %s", resource_type) + resources = get_resource_ids_by_resource_type( + tenant_id=self.tenant_id, + table_name=resource_type_data["table"], + uid=resource_type_data["uid"], + ) + for resource_id in resources: + resource_name = f"{resource_type}_{resource_id}" + resource_type_name = f"keep_{resource_type}" + self.create_resource( + resource_name=resource_name, + scopes=[], + resource_type=resource_type_name, + ) + self.logger.info("Resource created for object: %s", resource_type) for role in PREDEFINED_ROLES: self.logger.info("Creating role: %s", role) self.create_role(role, predefined=True) @@ -508,13 +556,18 @@ def get_auth_verifier(self, scopes: list) -> AuthVerifierBase: return KeycloakAuthVerifier(scopes) def create_resource( - self, resource_name: str, scopes: list[str] = [], resource_type="keep_generic" + self, + resource_name: str, + scopes: list[str] = [], + resource_type="keep_generic", + attributes={}, ) -> None: resource = { "name": resource_name, "displayName": f"Resource for {resource_name}", "type": "urn:keep:resources:" + resource_type, "scopes": [{"name": scope} for scope in scopes], + "attributes": attributes, } try: self.keycloak_admin.create_client_authz_resource(self.client_id, resource) @@ -655,7 +708,7 @@ def create_permissions(self, permissions: list[ResourcePermission]) -> None: { "name": permission.resource_id, "displayName": permission.resource_name, - "type": permission.resource_type, + "type": "urn:keep:resources:keep_" + permission.resource_type, "scopes": [], }, skip_exists=True, @@ -840,6 +893,7 @@ def get_user_permission_on_resource_type( # also, we should see how it scale with many resources try: user_id = self.keycloak_admin.get_user_id(authenticated_entity.email) + resource_type = f"urn:keep:resources:keep_{resource_type}" resp = self.keycloak_admin.connection.raw_post( f"{self.admin_url}/authz/resource-server/policy/evaluate", data=json.dumps( diff --git a/keep-ui/app/alerts/alert-push-alert-to-server-modal.tsx b/keep-ui/app/alerts/alert-push-alert-to-server-modal.tsx index a842a0641..76931c8ed 100644 --- a/keep-ui/app/alerts/alert-push-alert-to-server-modal.tsx +++ b/keep-ui/app/alerts/alert-push-alert-to-server-modal.tsx @@ -1,12 +1,11 @@ import React, { useState, useEffect } from "react"; +import { Button, Textarea, Subtitle, Callout } from "@tremor/react"; import { - Button, - Textarea, - Select, - SelectItem, - Subtitle, - Callout, -} from "@tremor/react"; + useForm, + Controller, + SubmitHandler, + FieldValues, +} from "react-hook-form"; import Modal from "@/components/ui/Modal"; import { useSession } from "next-auth/react"; import { useApiUrl } from "utils/hooks/useConfig"; @@ -14,6 +13,7 @@ import { useProviders } from "utils/hooks/useProviders"; import ImageWithFallback from "@/components/ImageWithFallback"; import { useAlerts } from "utils/hooks/useAlerts"; import { usePresets } from "utils/hooks/usePresets"; +import Select from "@/components/ui/Select"; interface PushAlertToServerModalProps { handleClose: () => void; @@ -31,10 +31,6 @@ const PushAlertToServerModal = ({ presetName, }: PushAlertToServerModalProps) => { const [alertSources, setAlertSources] = useState([]); - const [selectedSource, setSelectedSource] = useState( - null - ); - const [alertJson, setAlertJson] = useState(""); const { useAllPresets } = usePresets(); const { mutate: presetsMutator } = useAllPresets({ revalidateIfStale: false, @@ -43,6 +39,18 @@ const PushAlertToServerModal = ({ const { usePresetAlerts } = useAlerts(); const { mutate: mutateAlerts } = usePresetAlerts(presetName); + const { + control, + handleSubmit, + setValue, + setError, + clearErrors, + watch, + formState: { errors }, + } = useForm(); + + const selectedSource = watch("source"); + const { data: session } = useSession(); const { data: providersData } = useProviders(); const apiUrl = useApiUrl(); @@ -63,34 +71,25 @@ const PushAlertToServerModal = ({ } }, [providers]); - const handleSourceChange = (value: string) => { - const source = alertSources.find((source) => source.name === value); + const handleSourceChange = (source: AlertSource | null) => { if (source) { - setSelectedSource(source); - setAlertJson(source.alertExample); + setValue("source", source); + setValue("alertJson", source.alertExample); + clearErrors("source"); } }; - const handleJsonChange = (e: React.ChangeEvent) => { - setAlertJson(e.target.value); - }; - - const handleSubmit = async () => { - if (!selectedSource) { - console.error("No source selected"); - return; - } - + const onSubmit: SubmitHandler = async (data) => { try { const response = await fetch( - `${apiUrl}/alerts/event/${selectedSource.type}`, + `${apiUrl}/alerts/event/${data.source.type}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${session?.accessToken}`, }, - body: alertJson, + body: data.alertJson, } ); @@ -100,31 +99,21 @@ const PushAlertToServerModal = ({ presetsMutator(); handleClose(); } else { - console.error("Failed to push alert"); + const errorData = await response.json(); + setError("apiError", { + type: "manual", + message: errorData.detail || "Failed to push alert", + }); } } catch (error) { console.error("An unexpected error occurred", error); + setError("apiError", { + type: "manual", + message: "An unexpected error occurred", + }); } }; - const CustomSelectValue = ({ - selectedSource, - }: { - selectedSource: AlertSource; - }) => ( -
- - {selectedSource.name} -
- ); - return ( -
-
- - source.name} + formatOptionLabel={(source) => ( +
+ + {source.name.toLowerCase()} +
+ )} + getOptionValue={(source) => source.type} + placeholder="Select alert source" + /> + )} + /> + {errors.source && ( +
+ {errors.source.message?.toString()} +
)} - {alertSources.map((source) => ( - -
- - {source.name.toLowerCase()} -
-
- ))} - - - Feel free to edit the payload as you want. However, some of the - providers expects specific fields, so be careful. - -
- {selectedSource && ( - <> -
- -