From 67e51cae1eb4f19e4d7c253c3915c56986103ca8 Mon Sep 17 00:00:00 2001 From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com> Date: Thu, 9 Jan 2025 01:00:33 +0800 Subject: [PATCH 1/7] feat(app): Add OrganizationSetting table to db --- ...62975d7_add_organization_settings_table.py | 71 +++++++++++++++++++ tracecat/db/schemas.py | 27 +++++++ 2 files changed, 98 insertions(+) create mode 100644 alembic/versions/496c162975d7_add_organization_settings_table.py diff --git a/alembic/versions/496c162975d7_add_organization_settings_table.py b/alembic/versions/496c162975d7_add_organization_settings_table.py new file mode 100644 index 000000000..c3e0a55a6 --- /dev/null +++ b/alembic/versions/496c162975d7_add_organization_settings_table.py @@ -0,0 +1,71 @@ +"""Add organization settings table + +Revision ID: 496c162975d7 +Revises: 9194fb66b4ea +Create Date: 2025-01-09 00:02:47.484038 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "496c162975d7" +down_revision: str | None = "9194fb66b4ea" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "organization_settings", + sa.Column("surrogate_id", sa.Integer(), nullable=False), + sa.Column("owner_id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("id", sqlmodel.sql.sqltypes.GUID(), nullable=False), + sa.Column("key", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("value", sa.LargeBinary(), nullable=False), + sa.Column("value_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("is_encrypted", sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint("surrogate_id"), + ) + op.create_index( + op.f("ix_organization_settings_id"), + "organization_settings", + ["id"], + unique=True, + ) + op.create_index( + op.f("ix_organization_settings_key"), + "organization_settings", + ["key"], + unique=True, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_organization_settings_key"), table_name="organization_settings" + ) + op.drop_index( + op.f("ix_organization_settings_id"), table_name="organization_settings" + ) + op.drop_table("organization_settings") + # ### end Alembic commands ### diff --git a/tracecat/db/schemas.py b/tracecat/db/schemas.py index c78f08073..05acdb972 100644 --- a/tracecat/db/schemas.py +++ b/tracecat/db/schemas.py @@ -538,3 +538,30 @@ class RegistryAction(Resource, table=True): @property def action(self): return f"{self.namespace}.{self.name}" + + +class OrganizationSetting(Resource, table=True): + """An organization setting.""" + + __tablename__: str = "organization_settings" + + id: UUID4 = Field( + default_factory=uuid.uuid4, + nullable=False, + unique=True, + index=True, + ) + key: str = Field( + ..., + description="A unique key that identifies the setting", + index=True, + unique=True, + ) + value: bytes + value_type: str = Field( + ..., + description="The data type of the setting value", + ) + is_encrypted: bool = Field( + default=False, description="Whether the setting is encrypted" + ) From 498f3fddfd173a12b63da21a156c905a88886972 Mon Sep 17 00:00:00 2001 From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com> Date: Thu, 9 Jan 2025 14:09:42 +0800 Subject: [PATCH 2/7] feat: Add encrypt and decrypt value --- tests/unit/test_secrets.py | 52 +++++++++++++++++++++++++++++++++- tracecat/secrets/encryption.py | 43 +++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_secrets.py b/tests/unit/test_secrets.py index bb3f8cc6c..06520e205 100644 --- a/tests/unit/test_secrets.py +++ b/tests/unit/test_secrets.py @@ -1,7 +1,15 @@ import os +import pytest +from cryptography.fernet import Fernet, InvalidToken + from tracecat.secrets.common import apply_masks, apply_masks_object -from tracecat.secrets.encryption import decrypt_bytes, encrypt_bytes +from tracecat.secrets.encryption import ( + decrypt_bytes, + decrypt_value, + encrypt_bytes, + encrypt_value, +) def test_encrypt_decrypt_object(env_sandbox): @@ -11,6 +19,8 @@ def test_encrypt_decrypt_object(env_sandbox): "client_secret": "TEST_CLIENT_SECRET", "metadata": {"value": 1}, } + assert key is not None + encrypted_obj = encrypt_bytes(obj, key=key) decrypted_obj = decrypt_bytes(encrypted_obj, key=key) assert decrypted_obj == obj @@ -186,3 +196,43 @@ def test_apply_masks_object_with_heavily_nested_structures(): } } assert apply_masks_object(input_data, masks) == expected_data + + +@pytest.fixture +def test_encryption_key() -> str: + """Generate a valid Fernet encryption key for testing. + + Returns: + str: A base64-encoded 32-byte key suitable for Fernet encryption + """ + return Fernet.generate_key().decode() + + +def test_encrypt_decrypt_value(test_encryption_key): + """Test successful encryption and decryption of a value.""" + original_value = b"test secret value" + + # Test encryption + encrypted_value = encrypt_value(original_value, key=test_encryption_key) + assert isinstance(encrypted_value, bytes) + assert encrypted_value != original_value + + # Test decryption + decrypted_value = decrypt_value(encrypted_value, key=test_encryption_key) + assert decrypted_value == original_value + + +def test_decrypt_value_invalid_key(test_encryption_key): + """Test decryption with invalid key raises ValueError.""" + encrypted_value = encrypt_value(b"test value", key=test_encryption_key) + + with pytest.raises(ValueError): + decrypt_value(encrypted_value, key="invalid_key") + + +def test_decrypt_value_corrupted_token(test_encryption_key): + """Test decryption with corrupted token raises InvalidToken.""" + corrupted_value = b"corrupted_token" + + with pytest.raises(InvalidToken): + decrypt_value(corrupted_value, key=test_encryption_key) diff --git a/tracecat/secrets/encryption.py b/tracecat/secrets/encryption.py index f654e0d37..d0d79b63f 100644 --- a/tracecat/secrets/encryption.py +++ b/tracecat/secrets/encryption.py @@ -1,7 +1,7 @@ from typing import Any import orjson -from cryptography.fernet import Fernet +from cryptography.fernet import Fernet, InvalidToken from .models import SecretBase, SecretKeyValue @@ -30,3 +30,44 @@ def decrypt_keyvalues( def encrypt_keyvalues(keyvalues: list[SecretKeyValue], *, key: str) -> bytes: obj = {kv.key: kv.value.get_secret_value() for kv in keyvalues} return encrypt_bytes(obj, key=key) + + +def encrypt_value(value: bytes, *, key: str) -> bytes: + """Encrypt a string using Fernet encryption. + + Args: + value: The string to encrypt + key: The encryption key + + Returns: + str: The encrypted value as a base64-encoded string + + Raises: + ValueError: If the key is invalid + """ + try: + return Fernet(key).encrypt(value) + except Exception as e: + raise ValueError(f"Encryption failed: {str(e)}") from e + + +def decrypt_value(encrypted_value: bytes, *, key: str) -> bytes: + """Decrypt a Fernet-encrypted value back to a string. + + Args: + encrypted_value: The encrypted bytes + key: The decryption key + + Returns: + str: The decrypted string value + + Raises: + ValueError: If the key is invalid + InvalidToken: If the encrypted data is corrupted + """ + try: + return Fernet(key).decrypt(encrypted_value) + except InvalidToken as e: + raise InvalidToken("Decryption failed: corrupted or invalid token") from e + except Exception as e: + raise ValueError(f"Decryption failed: {str(e)}") from e From 958b98b1adcc8d37116ee48898e0334179de2b00 Mon Sep 17 00:00:00 2001 From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com> Date: Thu, 9 Jan 2025 14:38:18 +0800 Subject: [PATCH 3/7] feat(app): Implement settings service --- tracecat/settings/__init__.py | 0 tracecat/settings/constants.py | 14 ++ tracecat/settings/models.py | 122 +++++++++++++++ tracecat/settings/router.py | 157 +++++++++++++++++++ tracecat/settings/service.py | 270 +++++++++++++++++++++++++++++++++ 5 files changed, 563 insertions(+) create mode 100644 tracecat/settings/__init__.py create mode 100644 tracecat/settings/constants.py create mode 100644 tracecat/settings/models.py create mode 100644 tracecat/settings/router.py create mode 100644 tracecat/settings/service.py diff --git a/tracecat/settings/__init__.py b/tracecat/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tracecat/settings/constants.py b/tracecat/settings/constants.py new file mode 100644 index 000000000..ad4690744 --- /dev/null +++ b/tracecat/settings/constants.py @@ -0,0 +1,14 @@ +from tracecat.auth.enums import AuthType + +PUBLIC_SETTINGS_KEYS = {"auth_allowed_types"} +"""Settings that are allowed to be read by unauthenticated users. +Currently this is used in /info to serve config to the frontend.""" + +SENSITIVE_SETTINGS_KEYS = {"saml_idp_metadata_url"} +"""Settings that are encrypted at rest.""" + +AUTH_TYPE_TO_SETTING_KEY = { + AuthType.BASIC: "auth_basic_enabled", + AuthType.GOOGLE_OAUTH: "oauth_google_enabled", + AuthType.SAML: "saml_enabled", +} diff --git a/tracecat/settings/models.py b/tracecat/settings/models.py new file mode 100644 index 000000000..2e237b6b2 --- /dev/null +++ b/tracecat/settings/models.py @@ -0,0 +1,122 @@ +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field, ValidationInfo, field_validator + + +class BaseSettingsGroup(BaseModel): + """Base class for configurable settings.""" + + @classmethod + def keys(cls) -> set[str]: + """Get the setting keys as a set.""" + return set(cls.model_fields.keys()) + + +class GitSettingsRead(BaseSettingsGroup): + git_allowed_domains: list[str] + git_repo_url: str | None = Field(default=None) + git_repo_package_name: str | None = Field(default=None) + + +class GitSettingsUpdate(BaseSettingsGroup): + git_allowed_domains: list[str] = Field( + default_factory=lambda: ["github.com", "gitlab.com", "bitbucket.com"], + description="Allowed git domains for authentication.", + ) + git_repo_url: str | None = Field(default=None) + git_repo_package_name: str | None = Field(default=None) + + +class SAMLSettingsRead(BaseSettingsGroup): + saml_enabled: bool + saml_enforced: bool + saml_idp_metadata_url: str | None = Field(default=None) + saml_sp_acs_url: str | None = Field(default=None) + + @field_validator("saml_enforced", mode="before") + @classmethod + def validate_saml_enforced(cls, value: bool, info: ValidationInfo) -> bool: + """Validate that SAML enforcement requires SAML to be enabled.""" + if value and not info.data.get("saml_enabled", False): + raise ValueError("SAML must be enabled to enforce SAML authentication") + return value + + +class SAMLSettingsUpdate(BaseSettingsGroup): + saml_enabled: bool = Field(default=False, description="Whether SAML is enabled.") + saml_enforced: bool = Field( + default=False, + description="Whether SAML is enforced. If true, users can only use SAML to authenticate." + " Requires SAML to be enabled.", + ) + saml_idp_metadata_url: str | None = Field(default=None) + saml_sp_acs_url: str | None = Field(default=None) + + +class AuthSettingsRead(BaseSettingsGroup): + auth_basic_enabled: bool + auth_require_email_verification: bool + auth_allowed_email_domains: list[str] + auth_min_password_length: int + auth_session_expire_time_seconds: int + + +class AuthSettingsUpdate(BaseSettingsGroup): + auth_basic_enabled: bool = Field( + default=True, + description="Whether basic auth is enabled.", + ) + auth_require_email_verification: bool = Field( + default=False, + description="Whether email verification is required for authentication.", + ) + auth_allowed_email_domains: set[str] = Field( + default_factory=set, + description="Allowed email domains for authentication. If empty, all domains are allowed.", + ) + auth_min_password_length: int = Field( + default=12, + description="Minimum password length for authentication.", + ) + auth_session_expire_time_seconds: int = Field( + default=86400 * 7, # 1 week + description="Session expiration time in seconds.", + ) + + +class OAuthSettingsRead(BaseSettingsGroup): + """Settings for OAuth authentication.""" + + oauth_google_enabled: bool + + +class OAuthSettingsUpdate(BaseSettingsGroup): + """Settings for OAuth authentication.""" + + oauth_google_enabled: bool = Field( + default=True, description="Whether OAuth is enabled." + ) + + +class ValueType(StrEnum): + # This is the default type + JSON = "json" + """A physical JSON value""" + # Add custom types that map to particular pydantic models for more complex types + + +class SettingUpdate(BaseModel): + """Update a setting. Note that we don't allow updating the key and encryption status.""" + + value_type: ValueType | None = None + value: Any | None = None + + +class SettingCreate(BaseModel): + key: str + value_type: ValueType = ValueType.JSON + value: Any + is_sensitive: bool = Field( + description="Whether the setting is sensitive. Once set, it cannot be changed." + ) diff --git a/tracecat/settings/router.py b/tracecat/settings/router.py new file mode 100644 index 000000000..fd5ef7274 --- /dev/null +++ b/tracecat/settings/router.py @@ -0,0 +1,157 @@ +from typing import Annotated + +from fastapi import APIRouter, HTTPException, status + +from tracecat.auth.credentials import RoleACL +from tracecat.auth.dependencies import Role +from tracecat.auth.enums import AuthType +from tracecat.db.dependencies import AsyncDBSession +from tracecat.settings.constants import AUTH_TYPE_TO_SETTING_KEY +from tracecat.settings.models import ( + AuthSettingsRead, + AuthSettingsUpdate, + GitSettingsRead, + GitSettingsUpdate, + OAuthSettingsRead, + OAuthSettingsUpdate, + SAMLSettingsRead, + SAMLSettingsUpdate, +) +from tracecat.settings.service import SettingsService +from tracecat.types.auth import AccessLevel + +router = APIRouter(prefix="/settings", tags=["settings"]) + +OrgAdminUserRole = Annotated[ + Role, + RoleACL( + allow_user=True, + allow_service=False, + require_workspace="no", + min_access_level=AccessLevel.ADMIN, + ), +] + +# NOTE: We expose settings groups +# We don't need create or delete endpoints as we only need to read/update settings. +# For M2M, we use the service directly. + + +async def check_other_auth_enabled( + service: SettingsService, auth_type: AuthType +) -> None: + """Check if at least one other auth type is enabled.""" + + all_keys = set(AUTH_TYPE_TO_SETTING_KEY.values()) + all_keys.remove(AUTH_TYPE_TO_SETTING_KEY[auth_type]) + for key in all_keys: + setting = await service.get_org_setting(key) + if setting and service.get_value(setting) is True: + return + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="At least one other auth type must be enabled", + ) + + +@router.get("/git", response_model=GitSettingsRead) +async def get_git_settings( + *, + role: OrgAdminUserRole, + session: AsyncDBSession, +) -> GitSettingsRead: + service = SettingsService(session, role) + keys = GitSettingsRead.keys() + settings = await service.list_org_settings(keys=keys) + settings_dict = {setting.key: service.get_value(setting) for setting in settings} + return GitSettingsRead(**settings_dict) + + +@router.patch("/git", status_code=status.HTTP_204_NO_CONTENT) +async def update_git_settings( + *, + role: OrgAdminUserRole, + session: AsyncDBSession, + params: GitSettingsUpdate, +) -> None: + service = SettingsService(session, role) + await service.update_git_settings(params) + + +@router.get("/saml", response_model=SAMLSettingsRead) +async def get_saml_settings( + *, + role: OrgAdminUserRole, + session: AsyncDBSession, +) -> SAMLSettingsRead: + service = SettingsService(session, role) + keys = SAMLSettingsRead.keys() + settings = await service.list_org_settings(keys=keys) + settings_dict = {setting.key: service.get_value(setting) for setting in settings} + return SAMLSettingsRead(**settings_dict) + + +@router.patch("/saml", status_code=status.HTTP_204_NO_CONTENT) +async def update_saml_settings( + *, + role: OrgAdminUserRole, + session: AsyncDBSession, + params: SAMLSettingsUpdate, +) -> None: + service = SettingsService(session, role) + if not params.saml_enabled: + await check_other_auth_enabled(service, AuthType.SAML) + await service.update_saml_settings(params) + + +@router.get("/auth", response_model=AuthSettingsRead) +async def get_auth_settings( + *, + role: OrgAdminUserRole, + session: AsyncDBSession, +) -> AuthSettingsRead: + service = SettingsService(session, role) + keys = AuthSettingsRead.keys() + settings = await service.list_org_settings(keys=keys) + settings_dict = {setting.key: service.get_value(setting) for setting in settings} + return AuthSettingsRead(**settings_dict) + + +@router.patch("/auth", status_code=status.HTTP_204_NO_CONTENT) +async def update_auth_settings( + *, + role: OrgAdminUserRole, + session: AsyncDBSession, + params: AuthSettingsUpdate, +) -> None: + service = SettingsService(session, role) + if not params.auth_basic_enabled: + await check_other_auth_enabled(service, AuthType.BASIC) + await service.update_auth_settings(params) + + +@router.get("/oauth", response_model=OAuthSettingsRead) +async def get_oauth_settings( + *, + role: OrgAdminUserRole, + session: AsyncDBSession, +) -> OAuthSettingsRead: + service = SettingsService(session, role) + keys = OAuthSettingsRead.keys() + settings = await service.list_org_settings(keys=keys) + settings_dict = {setting.key: service.get_value(setting) for setting in settings} + return OAuthSettingsRead(**settings_dict) + + +@router.patch("/oauth", status_code=status.HTTP_204_NO_CONTENT) +async def update_oauth_settings( + *, + role: OrgAdminUserRole, + session: AsyncDBSession, + params: OAuthSettingsUpdate, +) -> None: + service = SettingsService(session, role) + # If we're trying to disable OAuth, we must have at least one other auth type enabled + if not params.oauth_google_enabled: + await check_other_auth_enabled(service, AuthType.GOOGLE_OAUTH) + await service.update_oauth_settings(params) diff --git a/tracecat/settings/service.py b/tracecat/settings/service.py new file mode 100644 index 000000000..aa2b2d133 --- /dev/null +++ b/tracecat/settings/service.py @@ -0,0 +1,270 @@ +import os +from collections.abc import Sequence +from typing import Any + +import orjson +from pydantic import BaseModel, SecretStr +from pydantic_core import to_jsonable_python +from sqlmodel import col, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from tracecat import config +from tracecat.authz.controls import require_access_level +from tracecat.db.schemas import OrganizationSetting +from tracecat.secrets.encryption import decrypt_value, encrypt_value +from tracecat.service import BaseService +from tracecat.settings.constants import PUBLIC_SETTINGS_KEYS, SENSITIVE_SETTINGS_KEYS +from tracecat.settings.models import ( + AuthSettingsUpdate, + BaseSettingsGroup, + GitSettingsUpdate, + OAuthSettingsUpdate, + SAMLSettingsUpdate, + SettingCreate, + SettingUpdate, +) +from tracecat.types.auth import AccessLevel, Role + + +class SettingsService(BaseService): + """Service for managing platform settings""" + + service_name = "settings" + groups: list[type[BaseSettingsGroup]] = [ + GitSettingsUpdate, + SAMLSettingsUpdate, + AuthSettingsUpdate, + OAuthSettingsUpdate, + ] + """The set of settings groups that are managed by the service.""" + + def __init__(self, session: AsyncSession, role: Role | None = None): + super().__init__(session, role=role) + try: + self._encryption_key = SecretStr(os.environ["TRACECAT__DB_ENCRYPTION_KEY"]) + except KeyError as e: + raise KeyError("TRACECAT__DB_ENCRYPTION_KEY is not set") from e + + def _serialize_value_bytes(self, value: Any) -> bytes: + return orjson.dumps( + value, default=to_jsonable_python, option=orjson.OPT_SORT_KEYS + ) + + def _deserialize_value_bytes(self, value: bytes) -> Any: + return orjson.loads(value) + + def _system_keys(self) -> set[str]: + """The set of keys that are reserved for system settings.""" + return {key for cls in self.groups for key in cls.keys()} + + async def init_default_settings(self): + for cls in self.groups: + for key, value in cls(): + if not await self.get_org_setting(key): + await self._create_org_setting( + SettingCreate( + key=key, + value=value, + is_sensitive=key in SENSITIVE_SETTINGS_KEYS, + ) + ) + self.logger.info("Created setting", key=key) + else: + self.logger.info("Setting already exists", key=key) + await self.session.commit() + + def get_value(self, setting: OrganizationSetting) -> Any: + value_bytes = setting.value + if setting.is_encrypted: + value_bytes = decrypt_value( + value_bytes, key=self._encryption_key.get_secret_value() + ) + return self._deserialize_value_bytes(value_bytes) + + async def list_org_settings( + self, + *, + keys: set[str] | None = None, + value_type: str | None = None, + is_encrypted: bool | None = None, + limit: int | None = None, + offset: int | None = None, + ) -> Sequence[OrganizationSetting]: + """List organization settings with optional filters. + + Args: + keys: Filter settings by a set of specific keys + value_type: Filter settings by their value type + is_encrypted: Filter settings by their encryption status + limit: Maximum number of settings to return + offset: Number of settings to skip for pagination + + Returns: + Sequence[OrganizationSetting]: List of matching organization settings + """ + statement = select(OrganizationSetting) + + if keys is not None: + statement = statement.where(col(OrganizationSetting.key).in_(keys)) + if value_type is not None: + statement = statement.where(OrganizationSetting.value_type == value_type) + if is_encrypted is not None: + statement = statement.where( + OrganizationSetting.is_encrypted == is_encrypted + ) + + if offset is not None: + statement = statement.offset(offset) + if limit is not None: + statement = statement.limit(limit) + + result = await self.session.exec(statement) + return result.all() + + async def get_org_setting(self, key: str) -> OrganizationSetting | None: + """Get the current organization settings. + + Returns: + Settings: The current organization settings configuration + """ + if self.role is None and key not in PUBLIC_SETTINGS_KEYS: + # Block access to private settings + self.logger.warning("Blocked attempted access to private setting", key=key) + return None + + statement = select(OrganizationSetting).where(OrganizationSetting.key == key) + result = await self.session.exec(statement) + return result.one_or_none() + + async def _create_org_setting(self, params: SettingCreate) -> OrganizationSetting: + """Create a new organization setting.""" + + # Convert to bytes + value_bytes = self._serialize_value_bytes(params.value) + # Then optionally encrypt + if params.is_sensitive: + value = encrypt_value( + value_bytes, key=self._encryption_key.get_secret_value() + ) + else: + value = value_bytes + setting = OrganizationSetting( + owner_id=config.TRACECAT__DEFAULT_ORG_ID, + key=params.key, + value_type=params.value_type, + value=value, + is_encrypted=params.is_sensitive, + ) + self.session.add(setting) + return setting + + @require_access_level(AccessLevel.ADMIN) + async def create_org_setting(self, params: SettingCreate) -> OrganizationSetting: + """Create a new organization setting.""" + setting = await self._create_org_setting(params) + await self.session.commit() + return setting + + async def _update_setting( + self, setting: OrganizationSetting, params: SettingUpdate + ) -> OrganizationSetting: + """Update a single organization setting but don't commit.""" + set_fields = params.model_dump(exclude_unset=True) + + # Handle value updates + if "value" in set_fields: + value_bytes = self._serialize_value_bytes(set_fields["value"]) + + # Use existing encryption status + if setting.is_encrypted: + setting.value = encrypt_value( + value_bytes, key=self._encryption_key.get_secret_value() + ) + else: + setting.value = value_bytes + + set_fields.pop("value") + + # Update any remaining fields (only value_type at this point) + for field, value in set_fields.items(): + setattr(setting, field, value) + return setting + + @require_access_level(AccessLevel.ADMIN) + async def update_org_setting( + self, setting: OrganizationSetting, params: SettingUpdate + ) -> OrganizationSetting: + """Update the organization settings. + + Args: + setting (OrganizationSetting): The existing setting to update + params (SettingUpdate): The new setting parameters to apply + + Returns: + OrganizationSetting: The updated settings configuration + """ + updated_setting = await self._update_setting(setting, params) + self.session.add(updated_setting) + await self.session.commit() + await self.session.refresh(updated_setting) + return updated_setting + + @require_access_level(AccessLevel.ADMIN) + async def delete_org_setting(self, setting: OrganizationSetting) -> None: + """Delete an organization setting.""" + if setting.key in self._system_keys(): + self.logger.warning( + "Cannot delete system setting", key=setting.key, setting=setting + ) + return + await self.session.delete(setting) + await self.session.commit() + + # Grouped settings + + async def _update_grouped_settings( + self, settings: Sequence[OrganizationSetting], params: BaseModel + ) -> None: + updated_fields = params.model_dump(exclude_unset=True) + for setting in settings: + if setting.key in updated_fields: + params = SettingUpdate(value=updated_fields[setting.key]) + await self._update_setting(setting, params) + await self.session.commit() + + @require_access_level(AccessLevel.ADMIN) + async def update_git_settings(self, params: GitSettingsUpdate) -> None: + self.logger.info(f"Updating Git settings: {params}") + # Ignore read-only fields + git_settings = await self.list_org_settings(keys=GitSettingsUpdate.keys()) + await self._update_grouped_settings(git_settings, params) + + @require_access_level(AccessLevel.ADMIN) + async def update_saml_settings(self, params: SAMLSettingsUpdate) -> None: + saml_settings = await self.list_org_settings(keys=SAMLSettingsUpdate.keys()) + await self._update_grouped_settings(saml_settings, params) + + @require_access_level(AccessLevel.ADMIN) + async def update_auth_settings(self, params: AuthSettingsUpdate) -> None: + auth_settings = await self.list_org_settings(keys=AuthSettingsUpdate.keys()) + await self._update_grouped_settings(auth_settings, params) + + @require_access_level(AccessLevel.ADMIN) + async def update_oauth_settings(self, params: OAuthSettingsUpdate) -> None: + oauth_settings = await self.list_org_settings(keys=OAuthSettingsUpdate.keys()) + await self._update_grouped_settings(oauth_settings, params) + + +async def get_setting( + key: str, *, role: Role | None = None, session: AsyncSession | None = None +) -> Any | None: + """Shorthand to get a setting value from the database.""" + if session: + service = SettingsService(session=session, role=role) + setting = await service.get_org_setting(key) + return service.get_value(setting) if setting else None + + else: + async with SettingsService.with_session(role=role) as service: + setting = await service.get_org_setting(key) + return service.get_value(setting) if setting else None From a665497d730e0f57a1bf903671aae551b2f8b977 Mon Sep 17 00:00:00 2001 From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com> Date: Thu, 9 Jan 2025 14:38:54 +0800 Subject: [PATCH 4/7] test: Add org settings tests --- tests/conftest.py | 15 +- tests/unit/test_organization_settings.py | 345 +++++++++++++++++++++++ 2 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_organization_settings.py diff --git a/tests/conftest.py b/tests/conftest.py index 935cd88a2..61508d16e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,7 @@ from tracecat.logger import logger from tracecat.registry.repositories.models import RegistryRepositoryCreate from tracecat.registry.repositories.service import RegistryReposService -from tracecat.types.auth import Role +from tracecat.types.auth import AccessLevel, Role from tracecat.workspaces.models import WorkspaceMetadataResponse @@ -380,6 +380,19 @@ async def svc_role(svc_workspace: Workspace) -> Role: """Service test fixture. Create a function scoped test role.""" return Role( type="user", + access_level=AccessLevel.BASIC, + workspace_id=svc_workspace.id, + user_id=uuid.uuid4(), + service_id="tracecat-api", + ) + + +@pytest.fixture +async def svc_admin_role(svc_workspace: Workspace) -> Role: + """Service test fixture. Create a function scoped test role.""" + return Role( + type="user", + access_level=AccessLevel.ADMIN, workspace_id=svc_workspace.id, user_id=uuid.uuid4(), service_id="tracecat-api", diff --git a/tests/unit/test_organization_settings.py b/tests/unit/test_organization_settings.py new file mode 100644 index 000000000..0a4537af5 --- /dev/null +++ b/tests/unit/test_organization_settings.py @@ -0,0 +1,345 @@ +import orjson +import pytest +from fastapi import HTTPException +from sqlmodel.ext.asyncio.session import AsyncSession + +from tracecat.auth.enums import AuthType +from tracecat.settings.models import ( + AuthSettingsUpdate, + GitSettingsUpdate, + OAuthSettingsUpdate, + SAMLSettingsUpdate, + SettingCreate, + SettingUpdate, + ValueType, +) +from tracecat.settings.router import check_other_auth_enabled +from tracecat.settings.service import SettingsService, get_setting +from tracecat.types.auth import Role + +pytestmark = pytest.mark.usefixtures("db") + + +@pytest.fixture(scope="function") +async def settings_service( + session: AsyncSession, svc_admin_role: Role +) -> SettingsService: + """Create a settings service instance for testing.""" + return SettingsService(session=session, role=svc_admin_role) + + +@pytest.fixture(scope="function") +async def settings_service_with_defaults( + session: AsyncSession, svc_admin_role: Role +) -> SettingsService: + """Create a settings service instance for testing.""" + service = SettingsService(session=session, role=svc_admin_role) + await service.init_default_settings() + return service + + +@pytest.fixture +def create_params() -> SettingCreate: + """Sample setting creation parameters.""" + return SettingCreate( + key="test-setting", + value={"test": "value"}, + value_type=ValueType.JSON, + is_sensitive=False, + ) + + +@pytest.mark.anyio +async def test_create_and_get_org_setting( + settings_service: SettingsService, create_params: SettingCreate +) -> None: + """Test creating and retrieving a setting.""" + # Create setting + created_setting = await settings_service.create_org_setting(create_params) + assert created_setting.key == create_params.key + assert settings_service.get_value(created_setting) == create_params.value + assert created_setting.value_type == create_params.value_type + assert created_setting.is_encrypted == create_params.is_sensitive + + # Retrieve setting + retrieved_setting = await settings_service.get_org_setting(created_setting.key) + assert retrieved_setting is not None + assert retrieved_setting.id == created_setting.id + assert retrieved_setting.key == create_params.key + assert settings_service.get_value(retrieved_setting) == settings_service.get_value( + created_setting + ) + + +@pytest.mark.anyio +async def test_list_org_settings( + settings_service: SettingsService, create_params: SettingCreate +) -> None: + """Test listing settings.""" + # Create multiple settings + setting1 = await settings_service.create_org_setting(create_params) + setting2 = await settings_service.create_org_setting( + SettingCreate( + key="test-setting-2", + value={"other": "value"}, + value_type=ValueType.JSON, + is_sensitive=True, + ) + ) + + # List all settings + settings = await settings_service.list_org_settings() + assert len(settings) >= 2 + setting_keys = {setting.key for setting in settings} + assert setting1.key in setting_keys + assert setting2.key in setting_keys + + +@pytest.mark.anyio +async def test_update_setting_admin( + settings_service: SettingsService, create_params: SettingCreate +) -> None: + """Test updating a setting as an admin.""" + # Create initial setting + created_setting = await settings_service.create_org_setting(create_params) + + # Update parameters + update_params = SettingUpdate( + value={"updated": "value"}, + value_type=ValueType.JSON, + ) + + # Update setting + updated_setting = await settings_service.update_org_setting( + created_setting, params=update_params + ) + assert settings_service.get_value(updated_setting) == update_params.value + assert updated_setting.value_type == update_params.value_type + + # Verify updates persisted + retrieved_setting = await settings_service.get_org_setting(created_setting.key) + assert retrieved_setting is not None + assert settings_service.get_value(retrieved_setting) == update_params.value + assert retrieved_setting.value_type == update_params.value_type + + +@pytest.mark.anyio +async def test_get_nonexistent_setting(settings_service: SettingsService) -> None: + """Test getting a setting that doesn't exist.""" + setting = await settings_service.get_org_setting("nonexistent-key") + assert setting is None + + +@pytest.mark.anyio +async def test_sensitive_setting_handling(settings_service: SettingsService) -> None: + """Test handling of encrypted settings.""" + sensitive_setting = await settings_service.create_org_setting( + SettingCreate( + key="sensitive-setting", + value=orjson.dumps({"secret": "value"}), + value_type=ValueType.JSON, + is_sensitive=True, + ) + ) + + assert sensitive_setting.is_encrypted is True + retrieved = await settings_service.get_org_setting(sensitive_setting.key) + assert retrieved is not None + assert retrieved.is_encrypted is True + + +@pytest.mark.anyio +async def test_delete_system_setting( + settings_service: SettingsService, + create_params: SettingCreate, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test attempting to delete an system setting, which should fail.""" + # Create system setting + monkeypatch.setattr(settings_service, "_system_keys", lambda: {create_params.key}) + + system_setting = await settings_service.create_org_setting(create_params) + + # Attempt to delete setting, should raise an error + await settings_service.delete_org_setting(system_setting) + + # Verify setting still exists + retrieved_setting = await settings_service.get_org_setting(system_setting.key) + assert retrieved_setting is not None + + +@pytest.mark.anyio +async def test_delete_mortal_setting( + settings_service: SettingsService, + create_params: SettingCreate, +) -> None: + """Test deleting a setting.""" + # Create setting + created_setting = await settings_service.create_org_setting(create_params) + + # Delete setting + await settings_service.delete_org_setting(created_setting) + + # Verify deletion + retrieved_setting = await settings_service.get_org_setting(created_setting.key) + assert retrieved_setting is None + + +@pytest.mark.anyio +async def test_update_git_settings( + settings_service_with_defaults: SettingsService, +) -> None: + """Test updating Git settings.""" + # Update Git settings with allowed domains and repo info + service = settings_service_with_defaults + test_params = GitSettingsUpdate( + git_allowed_domains=["github.com", "gitlab.com"], + git_repo_url="https://github.com/test/repo", + git_repo_package_name="test-package", + ) + await service.update_git_settings(test_params) + + # Verify updates + git_settings = await service.list_org_settings(keys=GitSettingsUpdate.keys()) + settings_dict = { + setting.key: service.get_value(setting) for setting in git_settings + } + assert settings_dict["git_allowed_domains"] == ["github.com", "gitlab.com"] + assert settings_dict["git_repo_url"] == "https://github.com/test/repo" + assert settings_dict["git_repo_package_name"] == "test-package" + + +@pytest.mark.anyio +async def test_update_saml_settings( + settings_service_with_defaults: SettingsService, +) -> None: + """Test updating SAML settings.""" + service = settings_service_with_defaults + + test_params = SAMLSettingsUpdate( + saml_enabled=True, + saml_idp_metadata_url="https://test-idp.com", + saml_sp_acs_url="https://test-sp.com", + ) + await service.update_saml_settings(test_params) + + saml_settings = await service.list_org_settings(keys=SAMLSettingsUpdate.keys()) + settings_dict = { + setting.key: service.get_value(setting) for setting in saml_settings + } + assert settings_dict["saml_enabled"] is True + assert settings_dict["saml_idp_metadata_url"] == "https://test-idp.com" + assert settings_dict["saml_sp_acs_url"] == "https://test-sp.com" + + +@pytest.mark.anyio +async def test_update_auth_settings( + settings_service_with_defaults: SettingsService, +) -> None: + """Test updating authentication settings.""" + service = settings_service_with_defaults + + test_params = AuthSettingsUpdate( + auth_basic_enabled=True, + auth_require_email_verification=True, + auth_allowed_email_domains={"test.com"}, + auth_min_password_length=16, + auth_session_expire_time_seconds=3600, + ) + await service.update_auth_settings(test_params) + + auth_settings = await service.list_org_settings(keys=AuthSettingsUpdate.keys()) + settings_dict = { + setting.key: service.get_value(setting) for setting in auth_settings + } + assert settings_dict["auth_basic_enabled"] is True + assert settings_dict["auth_require_email_verification"] is True + assert settings_dict["auth_allowed_email_domains"] == ["test.com"] # Returns a list + assert settings_dict["auth_min_password_length"] == 16 + assert settings_dict["auth_session_expire_time_seconds"] == 3600 + + +@pytest.mark.anyio +async def test_update_oauth_settings( + settings_service_with_defaults: SettingsService, +) -> None: + """Test updating OAuth settings.""" + service = settings_service_with_defaults + + test_params = OAuthSettingsUpdate(oauth_google_enabled=True) + await service.update_oauth_settings(test_params) + + oauth_settings = await service.list_org_settings(keys=OAuthSettingsUpdate.keys()) + settings_dict = { + setting.key: service.get_value(setting) for setting in oauth_settings + } + assert settings_dict["oauth_google_enabled"] is True + + +@pytest.mark.anyio +async def test_get_setting_shorthand( + settings_service: SettingsService, + create_params: SettingCreate, + svc_admin_role: Role, +) -> None: + """Test the get_setting shorthand function with and without roles.""" + # Create a test setting first + curr_session = settings_service.session + + created_setting = await settings_service.create_org_setting(create_params) + + # Test with valid role (should return value) + value = await get_setting( + created_setting.key, role=svc_admin_role, session=curr_session + ) + assert value == create_params.value + + # Test with no role (should return None) + no_role_value = await get_setting( + created_setting.key, role=None, session=curr_session + ) + assert no_role_value is None + + # Test retrieving non-existent setting + nonexistent_value = await get_setting( + "nonexistent-key", role=svc_admin_role, session=curr_session + ) + assert nonexistent_value is None + + +@pytest.mark.anyio +async def test_check_other_auth_enabled_success( + settings_service_with_defaults: SettingsService, +) -> None: + """Test check_other_auth_enabled when another auth type is enabled.""" + service = settings_service_with_defaults + + # Enable both SAML and Basic auth + await service.update_saml_settings(SAMLSettingsUpdate(saml_enabled=True)) + await service.update_auth_settings(AuthSettingsUpdate(auth_basic_enabled=True)) + + # Should not raise an exception when checking SAML (since Basic is enabled) + from tracecat.auth.enums import AuthType + from tracecat.settings.router import check_other_auth_enabled + + await check_other_auth_enabled(service, AuthType.SAML) + + +@pytest.mark.anyio +async def test_check_other_auth_enabled_failure( + settings_service_with_defaults: SettingsService, +) -> None: + """Test check_other_auth_enabled when no other auth type is enabled.""" + service = settings_service_with_defaults + + # Disable all auth types except Basic + await service.update_saml_settings(SAMLSettingsUpdate(saml_enabled=False)) + await service.update_oauth_settings(OAuthSettingsUpdate(oauth_google_enabled=False)) + await service.update_auth_settings(AuthSettingsUpdate(auth_basic_enabled=True)) + + # Should raise HTTPException when trying to disable the last enabled auth type + with pytest.raises(HTTPException) as exc_info: + await check_other_auth_enabled(service, AuthType.BASIC) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "At least one other auth type must be enabled" From d0ed2c9c667fc2f6d68361e35e62af19b9941884 Mon Sep 17 00:00:00 2001 From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com> Date: Thu, 9 Jan 2025 14:45:44 +0800 Subject: [PATCH 5/7] depr(app): Mark configs moved to service as deprecated --- tracecat/config.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tracecat/config.py b/tracecat/config.py index ba73e8991..09504503f 100644 --- a/tracecat/config.py +++ b/tracecat/config.py @@ -60,10 +60,14 @@ TRACECAT__DB_PORT = os.environ.get("TRACECAT__DB_PORT") # === Auth config === # +# Infrastructure config TRACECAT__AUTH_TYPES = { AuthType(t.lower()) for t in os.environ.get("TRACECAT__AUTH_TYPES", "basic,google_oauth").split(",") } +"""The set of allowed auth types on the platform. If an auth type is not in this set, +it cannot be enabled.""" + TRACECAT__AUTH_REQUIRE_EMAIL_VERIFICATION = os.environ.get( "TRACECAT__AUTH_REQUIRE_EMAIL_VERIFICATION", "" ).lower() in ("true", "1") # Default to False @@ -74,6 +78,8 @@ ((domains := os.getenv("TRACECAT__AUTH_ALLOWED_DOMAINS")) and domains.split(",")) or [] ) +"""Deprecated: This config has been moved into the settings service""" + TRACECAT__AUTH_MIN_PASSWORD_LENGTH = int( os.environ.get("TRACECAT__AUTH_MIN_PASSWORD_LENGTH") or 12 ) @@ -93,10 +99,16 @@ # SAML SSO SAML_IDP_CERTIFICATE = os.environ.get("SAML_IDP_CERTIFICATE") +"""Deprecated: This config has been removed""" + SAML_IDP_METADATA_URL = os.environ.get("SAML_IDP_METADATA_URL") +"""Deprecated: This config has been moved into the settings service""" + SAML_SP_ACS_URL = os.environ.get( "SAML_SP_ACS_URL", "http://localhost/api/auth/saml/acs" ) +"""Deprecated: This config has been moved into the settings service""" + XMLSEC_BINARY_PATH = os.environ.get("XMLSEC_BINARY_PATH", "/usr/bin/xmlsec1") # === CORS config === # @@ -146,15 +158,20 @@ "TRACECAT__ALLOWED_GIT_DOMAINS", "github.com,gitlab.com,bitbucket.org" ).split(",") ) +"""Deprecated: This config has been moved into the settings service""" # If you wish to use a remote registry, set the URL here # If the url is unset, this will be set to None TRACECAT__REMOTE_REPOSITORY_URL = ( os.environ.get("TRACECAT__REMOTE_REPOSITORY_URL") or None ) +"""Deprecated: This config has been moved into the settings service""" TRACECAT__REMOTE_REPOSITORY_PACKAGE_NAME = os.getenv( "TRACECAT__REMOTE_REPOSITORY_PACKAGE_NAME" ) -"""If not provided, the package name will be inferred from the git remote URL.""" +"""If not provided, the package name will be inferred from the git remote URL. + +Deprecated: This config has been moved into the settings service +""" # === Email settings === # TRACECAT__ALLOWED_EMAIL_ATTRIBUTES = os.environ.get( From c32d24672df2fedf34fd3427b220d7deb1037eae Mon Sep 17 00:00:00 2001 From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com> Date: Thu, 9 Jan 2025 14:46:49 +0800 Subject: [PATCH 6/7] chore(ui): Update openapi client --- frontend/src/client/schemas.gen.ts | 275 ++++++++++++++++++++++++++++ frontend/src/client/services.gen.ts | 140 +++++++++++++- frontend/src/client/types.gen.ts | 248 +++++++++++++++++++++++++ 3 files changed, 662 insertions(+), 1 deletion(-) diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 005538116..a57902484 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -338,6 +338,77 @@ export const $ActionUpdate = { title: 'ActionUpdate' } as const; +export const $AuthSettingsRead = { + properties: { + auth_basic_enabled: { + type: 'boolean', + title: 'Auth Basic Enabled' + }, + auth_require_email_verification: { + type: 'boolean', + title: 'Auth Require Email Verification' + }, + auth_allowed_email_domains: { + items: { + type: 'string' + }, + type: 'array', + title: 'Auth Allowed Email Domains' + }, + auth_min_password_length: { + type: 'integer', + title: 'Auth Min Password Length' + }, + auth_session_expire_time_seconds: { + type: 'integer', + title: 'Auth Session Expire Time Seconds' + } + }, + type: 'object', + required: ['auth_basic_enabled', 'auth_require_email_verification', 'auth_allowed_email_domains', 'auth_min_password_length', 'auth_session_expire_time_seconds'], + title: 'AuthSettingsRead' +} as const; + +export const $AuthSettingsUpdate = { + properties: { + auth_basic_enabled: { + type: 'boolean', + title: 'Auth Basic Enabled', + description: 'Whether basic auth is enabled.', + default: true + }, + auth_require_email_verification: { + type: 'boolean', + title: 'Auth Require Email Verification', + description: 'Whether email verification is required for authentication.', + default: false + }, + auth_allowed_email_domains: { + items: { + type: 'string' + }, + type: 'array', + uniqueItems: true, + title: 'Auth Allowed Email Domains', + description: 'Allowed email domains for authentication. If empty, all domains are allowed.' + }, + auth_min_password_length: { + type: 'integer', + title: 'Auth Min Password Length', + description: 'Minimum password length for authentication.', + default: 12 + }, + auth_session_expire_time_seconds: { + type: 'integer', + title: 'Auth Session Expire Time Seconds', + description: 'Session expiration time in seconds.', + default: 604800 + } + }, + type: 'object', + title: 'AuthSettingsUpdate' +} as const; + export const $Body_auth_reset_forgot_password = { properties: { email: { @@ -1182,6 +1253,80 @@ export const $GetWorkflowDefinitionActivityInputs = { title: 'GetWorkflowDefinitionActivityInputs' } as const; +export const $GitSettingsRead = { + properties: { + git_allowed_domains: { + items: { + type: 'string' + }, + type: 'array', + title: 'Git Allowed Domains' + }, + git_repo_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Git Repo Url' + }, + git_repo_package_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Git Repo Package Name' + } + }, + type: 'object', + required: ['git_allowed_domains'], + title: 'GitSettingsRead' +} as const; + +export const $GitSettingsUpdate = { + properties: { + git_allowed_domains: { + items: { + type: 'string' + }, + type: 'array', + title: 'Git Allowed Domains', + description: 'Allowed git domains for authentication.' + }, + git_repo_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Git Repo Url' + }, + git_repo_package_name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Git Repo Package Name' + } + }, + type: 'object', + title: 'GitSettingsUpdate' +} as const; + export const $HTTPValidationError = { properties: { detail: { @@ -1214,6 +1359,33 @@ export const $OAuth2AuthorizeResponse = { title: 'OAuth2AuthorizeResponse' } as const; +export const $OAuthSettingsRead = { + properties: { + oauth_google_enabled: { + type: 'boolean', + title: 'Oauth Google Enabled' + } + }, + type: 'object', + required: ['oauth_google_enabled'], + title: 'OAuthSettingsRead', + description: 'Settings for OAuth authentication.' +} as const; + +export const $OAuthSettingsUpdate = { + properties: { + oauth_google_enabled: { + type: 'boolean', + title: 'Oauth Google Enabled', + description: 'Whether OAuth is enabled.', + default: true + } + }, + type: 'object', + title: 'OAuthSettingsUpdate', + description: 'Settings for OAuth authentication.' +} as const; + export const $OrgMemberRead = { properties: { user_id: { @@ -2131,6 +2303,85 @@ export const $SAMLDatabaseLoginResponse = { title: 'SAMLDatabaseLoginResponse' } as const; +export const $SAMLSettingsRead = { + properties: { + saml_enabled: { + type: 'boolean', + title: 'Saml Enabled' + }, + saml_enforced: { + type: 'boolean', + title: 'Saml Enforced' + }, + saml_idp_metadata_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Saml Idp Metadata Url' + }, + saml_sp_acs_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Saml Sp Acs Url' + } + }, + type: 'object', + required: ['saml_enabled', 'saml_enforced'], + title: 'SAMLSettingsRead' +} as const; + +export const $SAMLSettingsUpdate = { + properties: { + saml_enabled: { + type: 'boolean', + title: 'Saml Enabled', + description: 'Whether SAML is enabled.', + default: false + }, + saml_enforced: { + type: 'boolean', + title: 'Saml Enforced', + description: 'Whether SAML is enforced. If true, users can only use SAML to authenticate. Requires SAML to be enabled.', + default: false + }, + saml_idp_metadata_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Saml Idp Metadata Url' + }, + saml_sp_acs_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Saml Sp Acs Url' + } + }, + type: 'object', + title: 'SAMLSettingsUpdate' +} as const; + export const $Schedule = { properties: { owner_id: { @@ -2823,6 +3074,24 @@ export const $SessionRead = { title: 'SessionRead' } as const; +export const $SettingRead = { + properties: { + key: { + type: 'string', + title: 'Key' + }, + value_type: { + '$ref': '#/components/schemas/ValueType' + }, + value: { + title: 'Value' + } + }, + type: 'object', + required: ['key', 'value_type', 'value'], + title: 'SettingRead' +} as const; + export const $TagCreate = { properties: { name: { @@ -3443,6 +3712,12 @@ export const $ValidationError = { title: 'ValidationError' } as const; +export const $ValueType = { + type: 'string', + const: 'json', + title: 'ValueType' +} as const; + export const $WebhookResponse = { properties: { owner_id: { diff --git a/frontend/src/client/services.gen.ts b/frontend/src/client/services.gen.ts index cd395a04d..fe80e160b 100644 --- a/frontend/src/client/services.gen.ts +++ b/frontend/src/client/services.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { PublicIncomingWebhookData, PublicIncomingWebhookResponse, PublicIncomingWebhookWaitData, PublicIncomingWebhookWaitResponse, WorkspacesListWorkspacesResponse, WorkspacesCreateWorkspaceData, WorkspacesCreateWorkspaceResponse, WorkspacesSearchWorkspacesData, WorkspacesSearchWorkspacesResponse, WorkspacesGetWorkspaceData, WorkspacesGetWorkspaceResponse, WorkspacesUpdateWorkspaceData, WorkspacesUpdateWorkspaceResponse, WorkspacesDeleteWorkspaceData, WorkspacesDeleteWorkspaceResponse, WorkspacesListWorkspaceMembershipsData, WorkspacesListWorkspaceMembershipsResponse, WorkspacesCreateWorkspaceMembershipData, WorkspacesCreateWorkspaceMembershipResponse, WorkspacesGetWorkspaceMembershipData, WorkspacesGetWorkspaceMembershipResponse, WorkspacesDeleteWorkspaceMembershipData, WorkspacesDeleteWorkspaceMembershipResponse, WorkflowsListWorkflowsData, WorkflowsListWorkflowsResponse, WorkflowsCreateWorkflowData, WorkflowsCreateWorkflowResponse, WorkflowsGetWorkflowData, WorkflowsGetWorkflowResponse, WorkflowsUpdateWorkflowData, WorkflowsUpdateWorkflowResponse, WorkflowsDeleteWorkflowData, WorkflowsDeleteWorkflowResponse, WorkflowsCommitWorkflowData, WorkflowsCommitWorkflowResponse, WorkflowsExportWorkflowData, WorkflowsExportWorkflowResponse, WorkflowsGetWorkflowDefinitionData, WorkflowsGetWorkflowDefinitionResponse, WorkflowsCreateWorkflowDefinitionData, WorkflowsCreateWorkflowDefinitionResponse, TriggersCreateWebhookData, TriggersCreateWebhookResponse, TriggersGetWebhookData, TriggersGetWebhookResponse, TriggersUpdateWebhookData, TriggersUpdateWebhookResponse, WorkflowExecutionsListWorkflowExecutionsData, WorkflowExecutionsListWorkflowExecutionsResponse, WorkflowExecutionsCreateWorkflowExecutionData, WorkflowExecutionsCreateWorkflowExecutionResponse, WorkflowExecutionsGetWorkflowExecutionData, WorkflowExecutionsGetWorkflowExecutionResponse, WorkflowExecutionsListWorkflowExecutionEventHistoryData, WorkflowExecutionsListWorkflowExecutionEventHistoryResponse, WorkflowExecutionsCancelWorkflowExecutionData, WorkflowExecutionsCancelWorkflowExecutionResponse, WorkflowExecutionsTerminateWorkflowExecutionData, WorkflowExecutionsTerminateWorkflowExecutionResponse, ActionsListActionsData, ActionsListActionsResponse, ActionsCreateActionData, ActionsCreateActionResponse, ActionsGetActionData, ActionsGetActionResponse, ActionsUpdateActionData, ActionsUpdateActionResponse, ActionsDeleteActionData, ActionsDeleteActionResponse, WorkflowsListTagsData, WorkflowsListTagsResponse, WorkflowsAddTagData, WorkflowsAddTagResponse, WorkflowsRemoveTagData, WorkflowsRemoveTagResponse, SecretsSearchSecretsData, SecretsSearchSecretsResponse, SecretsListSecretsData, SecretsListSecretsResponse, SecretsCreateSecretData, SecretsCreateSecretResponse, SecretsGetSecretByNameData, SecretsGetSecretByNameResponse, SecretsUpdateSecretByIdData, SecretsUpdateSecretByIdResponse, SecretsDeleteSecretByIdData, SecretsDeleteSecretByIdResponse, SchedulesListSchedulesData, SchedulesListSchedulesResponse, SchedulesCreateScheduleData, SchedulesCreateScheduleResponse, SchedulesGetScheduleData, SchedulesGetScheduleResponse, SchedulesUpdateScheduleData, SchedulesUpdateScheduleResponse, SchedulesDeleteScheduleData, SchedulesDeleteScheduleResponse, SchedulesSearchSchedulesData, SchedulesSearchSchedulesResponse, TagsListTagsData, TagsListTagsResponse, TagsCreateTagData, TagsCreateTagResponse, TagsGetTagData, TagsGetTagResponse, TagsUpdateTagData, TagsUpdateTagResponse, TagsDeleteTagData, TagsDeleteTagResponse, UsersSearchUserData, UsersSearchUserResponse, OrganizationListOrgMembersResponse, OrganizationDeleteOrgMemberData, OrganizationDeleteOrgMemberResponse, OrganizationUpdateOrgMemberData, OrganizationUpdateOrgMemberResponse, OrganizationListSessionsResponse, OrganizationDeleteSessionData, OrganizationDeleteSessionResponse, EditorListFunctionsData, EditorListFunctionsResponse, EditorListActionsData, EditorListActionsResponse, RegistryRepositoriesSyncRegistryRepositoryData, RegistryRepositoriesSyncRegistryRepositoryResponse, RegistryRepositoriesSyncExecutorFromRegistryRepositoryData, RegistryRepositoriesSyncExecutorFromRegistryRepositoryResponse, RegistryRepositoriesListRegistryRepositoriesResponse, RegistryRepositoriesCreateRegistryRepositoryData, RegistryRepositoriesCreateRegistryRepositoryResponse, RegistryRepositoriesGetRegistryRepositoryData, RegistryRepositoriesGetRegistryRepositoryResponse, RegistryRepositoriesUpdateRegistryRepositoryData, RegistryRepositoriesUpdateRegistryRepositoryResponse, RegistryRepositoriesDeleteRegistryRepositoryData, RegistryRepositoriesDeleteRegistryRepositoryResponse, RegistryActionsListRegistryActionsResponse, RegistryActionsCreateRegistryActionData, RegistryActionsCreateRegistryActionResponse, RegistryActionsGetRegistryActionData, RegistryActionsGetRegistryActionResponse, RegistryActionsUpdateRegistryActionData, RegistryActionsUpdateRegistryActionResponse, RegistryActionsDeleteRegistryActionData, RegistryActionsDeleteRegistryActionResponse, UsersUsersCurrentUserResponse, UsersUsersPatchCurrentUserData, UsersUsersPatchCurrentUserResponse, UsersUsersUserData, UsersUsersUserResponse, UsersUsersPatchUserData, UsersUsersPatchUserResponse, UsersUsersDeleteUserData, UsersUsersDeleteUserResponse, AuthAuthDatabaseLoginData, AuthAuthDatabaseLoginResponse, AuthAuthDatabaseLogoutResponse, AuthRegisterRegisterData, AuthRegisterRegisterResponse, AuthResetForgotPasswordData, AuthResetForgotPasswordResponse, AuthResetResetPasswordData, AuthResetResetPasswordResponse, AuthVerifyRequestTokenData, AuthVerifyRequestTokenResponse, AuthVerifyVerifyData, AuthVerifyVerifyResponse, AuthOauthGoogleDatabaseAuthorizeData, AuthOauthGoogleDatabaseAuthorizeResponse, AuthOauthGoogleDatabaseCallbackData, AuthOauthGoogleDatabaseCallbackResponse, AuthSamlDatabaseLoginResponse, AuthSsoAcsData, AuthSsoAcsResponse, PublicCheckHealthResponse } from './types.gen'; +import type { PublicIncomingWebhookData, PublicIncomingWebhookResponse, PublicIncomingWebhookWaitData, PublicIncomingWebhookWaitResponse, WorkspacesListWorkspacesResponse, WorkspacesCreateWorkspaceData, WorkspacesCreateWorkspaceResponse, WorkspacesSearchWorkspacesData, WorkspacesSearchWorkspacesResponse, WorkspacesGetWorkspaceData, WorkspacesGetWorkspaceResponse, WorkspacesUpdateWorkspaceData, WorkspacesUpdateWorkspaceResponse, WorkspacesDeleteWorkspaceData, WorkspacesDeleteWorkspaceResponse, WorkspacesListWorkspaceMembershipsData, WorkspacesListWorkspaceMembershipsResponse, WorkspacesCreateWorkspaceMembershipData, WorkspacesCreateWorkspaceMembershipResponse, WorkspacesGetWorkspaceMembershipData, WorkspacesGetWorkspaceMembershipResponse, WorkspacesDeleteWorkspaceMembershipData, WorkspacesDeleteWorkspaceMembershipResponse, WorkflowsListWorkflowsData, WorkflowsListWorkflowsResponse, WorkflowsCreateWorkflowData, WorkflowsCreateWorkflowResponse, WorkflowsGetWorkflowData, WorkflowsGetWorkflowResponse, WorkflowsUpdateWorkflowData, WorkflowsUpdateWorkflowResponse, WorkflowsDeleteWorkflowData, WorkflowsDeleteWorkflowResponse, WorkflowsCommitWorkflowData, WorkflowsCommitWorkflowResponse, WorkflowsExportWorkflowData, WorkflowsExportWorkflowResponse, WorkflowsGetWorkflowDefinitionData, WorkflowsGetWorkflowDefinitionResponse, WorkflowsCreateWorkflowDefinitionData, WorkflowsCreateWorkflowDefinitionResponse, TriggersCreateWebhookData, TriggersCreateWebhookResponse, TriggersGetWebhookData, TriggersGetWebhookResponse, TriggersUpdateWebhookData, TriggersUpdateWebhookResponse, WorkflowExecutionsListWorkflowExecutionsData, WorkflowExecutionsListWorkflowExecutionsResponse, WorkflowExecutionsCreateWorkflowExecutionData, WorkflowExecutionsCreateWorkflowExecutionResponse, WorkflowExecutionsGetWorkflowExecutionData, WorkflowExecutionsGetWorkflowExecutionResponse, WorkflowExecutionsListWorkflowExecutionEventHistoryData, WorkflowExecutionsListWorkflowExecutionEventHistoryResponse, WorkflowExecutionsCancelWorkflowExecutionData, WorkflowExecutionsCancelWorkflowExecutionResponse, WorkflowExecutionsTerminateWorkflowExecutionData, WorkflowExecutionsTerminateWorkflowExecutionResponse, ActionsListActionsData, ActionsListActionsResponse, ActionsCreateActionData, ActionsCreateActionResponse, ActionsGetActionData, ActionsGetActionResponse, ActionsUpdateActionData, ActionsUpdateActionResponse, ActionsDeleteActionData, ActionsDeleteActionResponse, WorkflowsListTagsData, WorkflowsListTagsResponse, WorkflowsAddTagData, WorkflowsAddTagResponse, WorkflowsRemoveTagData, WorkflowsRemoveTagResponse, SecretsSearchSecretsData, SecretsSearchSecretsResponse, SecretsListSecretsData, SecretsListSecretsResponse, SecretsCreateSecretData, SecretsCreateSecretResponse, SecretsGetSecretByNameData, SecretsGetSecretByNameResponse, SecretsUpdateSecretByIdData, SecretsUpdateSecretByIdResponse, SecretsDeleteSecretByIdData, SecretsDeleteSecretByIdResponse, SchedulesListSchedulesData, SchedulesListSchedulesResponse, SchedulesCreateScheduleData, SchedulesCreateScheduleResponse, SchedulesGetScheduleData, SchedulesGetScheduleResponse, SchedulesUpdateScheduleData, SchedulesUpdateScheduleResponse, SchedulesDeleteScheduleData, SchedulesDeleteScheduleResponse, SchedulesSearchSchedulesData, SchedulesSearchSchedulesResponse, TagsListTagsData, TagsListTagsResponse, TagsCreateTagData, TagsCreateTagResponse, TagsGetTagData, TagsGetTagResponse, TagsUpdateTagData, TagsUpdateTagResponse, TagsDeleteTagData, TagsDeleteTagResponse, UsersSearchUserData, UsersSearchUserResponse, OrganizationListOrgMembersResponse, OrganizationDeleteOrgMemberData, OrganizationDeleteOrgMemberResponse, OrganizationUpdateOrgMemberData, OrganizationUpdateOrgMemberResponse, OrganizationListSessionsResponse, OrganizationDeleteSessionData, OrganizationDeleteSessionResponse, EditorListFunctionsData, EditorListFunctionsResponse, EditorListActionsData, EditorListActionsResponse, RegistryRepositoriesReloadRegistryRepositoriesResponse, RegistryRepositoriesSyncRegistryRepositoryData, RegistryRepositoriesSyncRegistryRepositoryResponse, RegistryRepositoriesSyncExecutorFromRegistryRepositoryData, RegistryRepositoriesSyncExecutorFromRegistryRepositoryResponse, RegistryRepositoriesListRegistryRepositoriesResponse, RegistryRepositoriesCreateRegistryRepositoryData, RegistryRepositoriesCreateRegistryRepositoryResponse, RegistryRepositoriesGetRegistryRepositoryData, RegistryRepositoriesGetRegistryRepositoryResponse, RegistryRepositoriesUpdateRegistryRepositoryData, RegistryRepositoriesUpdateRegistryRepositoryResponse, RegistryRepositoriesDeleteRegistryRepositoryData, RegistryRepositoriesDeleteRegistryRepositoryResponse, RegistryActionsListRegistryActionsResponse, RegistryActionsCreateRegistryActionData, RegistryActionsCreateRegistryActionResponse, RegistryActionsGetRegistryActionData, RegistryActionsGetRegistryActionResponse, RegistryActionsUpdateRegistryActionData, RegistryActionsUpdateRegistryActionResponse, RegistryActionsDeleteRegistryActionData, RegistryActionsDeleteRegistryActionResponse, SettingsGetGitSettingsResponse, SettingsUpdateGitSettingsData, SettingsUpdateGitSettingsResponse, SettingsGetSamlSettingsResponse, SettingsUpdateSamlSettingsData, SettingsUpdateSamlSettingsResponse, SettingsGetAuthSettingsResponse, SettingsUpdateAuthSettingsData, SettingsUpdateAuthSettingsResponse, SettingsGetOauthSettingsResponse, SettingsUpdateOauthSettingsData, SettingsUpdateOauthSettingsResponse, SettingsListSettingsData, SettingsListSettingsResponse, UsersUsersCurrentUserResponse, UsersUsersPatchCurrentUserData, UsersUsersPatchCurrentUserResponse, UsersUsersUserData, UsersUsersUserResponse, UsersUsersPatchUserData, UsersUsersPatchUserResponse, UsersUsersDeleteUserData, UsersUsersDeleteUserResponse, AuthAuthDatabaseLoginData, AuthAuthDatabaseLoginResponse, AuthAuthDatabaseLogoutResponse, AuthRegisterRegisterData, AuthRegisterRegisterResponse, AuthResetForgotPasswordData, AuthResetForgotPasswordResponse, AuthResetResetPasswordData, AuthResetResetPasswordResponse, AuthVerifyRequestTokenData, AuthVerifyRequestTokenResponse, AuthVerifyVerifyData, AuthVerifyVerifyResponse, AuthOauthGoogleDatabaseAuthorizeData, AuthOauthGoogleDatabaseAuthorizeResponse, AuthOauthGoogleDatabaseCallbackData, AuthOauthGoogleDatabaseCallbackResponse, AuthSamlDatabaseLoginResponse, AuthSsoAcsData, AuthSsoAcsResponse, PublicCheckHealthResponse } from './types.gen'; /** * Incoming Webhook @@ -1413,6 +1413,17 @@ export const editorListActions = (data: EditorListActionsData): CancelablePromis } }); }; +/** + * Reload Registry Repositories + * Refresh all registry repositories. + * @returns void Successful Response + * @throws ApiError + */ +export const registryRepositoriesReloadRegistryRepositories = (): CancelablePromise => { return __request(OpenAPI, { + method: 'POST', + url: '/registry/repos/reload' +}); }; + /** * Sync Registry Repository * Load actions from a specific registry repository. @@ -1628,6 +1639,133 @@ export const registryActionsDeleteRegistryAction = (data: RegistryActionsDeleteR } }); }; +/** + * Get Git Settings + * @returns GitSettingsRead Successful Response + * @throws ApiError + */ +export const settingsGetGitSettings = (): CancelablePromise => { return __request(OpenAPI, { + method: 'GET', + url: '/settings/git' +}); }; + +/** + * Update Git Settings + * @param data The data for the request. + * @param data.requestBody + * @returns void Successful Response + * @throws ApiError + */ +export const settingsUpdateGitSettings = (data: SettingsUpdateGitSettingsData): CancelablePromise => { return __request(OpenAPI, { + method: 'PATCH', + url: '/settings/git', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } +}); }; + +/** + * Get Saml Settings + * @returns SAMLSettingsRead Successful Response + * @throws ApiError + */ +export const settingsGetSamlSettings = (): CancelablePromise => { return __request(OpenAPI, { + method: 'GET', + url: '/settings/saml' +}); }; + +/** + * Update Saml Settings + * @param data The data for the request. + * @param data.requestBody + * @returns void Successful Response + * @throws ApiError + */ +export const settingsUpdateSamlSettings = (data: SettingsUpdateSamlSettingsData): CancelablePromise => { return __request(OpenAPI, { + method: 'PATCH', + url: '/settings/saml', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } +}); }; + +/** + * Get Auth Settings + * @returns AuthSettingsRead Successful Response + * @throws ApiError + */ +export const settingsGetAuthSettings = (): CancelablePromise => { return __request(OpenAPI, { + method: 'GET', + url: '/settings/auth' +}); }; + +/** + * Update Auth Settings + * @param data The data for the request. + * @param data.requestBody + * @returns void Successful Response + * @throws ApiError + */ +export const settingsUpdateAuthSettings = (data: SettingsUpdateAuthSettingsData): CancelablePromise => { return __request(OpenAPI, { + method: 'PATCH', + url: '/settings/auth', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } +}); }; + +/** + * Get Oauth Settings + * @returns OAuthSettingsRead Successful Response + * @throws ApiError + */ +export const settingsGetOauthSettings = (): CancelablePromise => { return __request(OpenAPI, { + method: 'GET', + url: '/settings/oauth' +}); }; + +/** + * Update Oauth Settings + * @param data The data for the request. + * @param data.requestBody + * @returns void Successful Response + * @throws ApiError + */ +export const settingsUpdateOauthSettings = (data: SettingsUpdateOauthSettingsData): CancelablePromise => { return __request(OpenAPI, { + method: 'PATCH', + url: '/settings/oauth', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } +}); }; + +/** + * List Settings + * List organization settings with optional filters. + * @param data The data for the request. + * @param data.key + * @returns SettingRead Successful Response + * @throws ApiError + */ +export const settingsListSettings = (data: SettingsListSettingsData = {}): CancelablePromise => { return __request(OpenAPI, { + method: 'GET', + url: '/settings', + query: { + key: data.key + }, + errors: { + 422: 'Validation Error' + } +}); }; + /** * Users:Current User * @returns UserRead Successful Response diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index d64335e31..f7ddbbd01 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -117,6 +117,37 @@ export type ActionUpdate = { control_flow?: ActionControlFlow | null; }; +export type AuthSettingsRead = { + auth_basic_enabled: boolean; + auth_require_email_verification: boolean; + auth_allowed_email_domains: Array<(string)>; + auth_min_password_length: number; + auth_session_expire_time_seconds: number; +}; + +export type AuthSettingsUpdate = { + /** + * Whether basic auth is enabled. + */ + auth_basic_enabled?: boolean; + /** + * Whether email verification is required for authentication. + */ + auth_require_email_verification?: boolean; + /** + * Allowed email domains for authentication. If empty, all domains are allowed. + */ + auth_allowed_email_domains?: Array<(string)>; + /** + * Minimum password length for authentication. + */ + auth_min_password_length?: number; + /** + * Session expiration time in seconds. + */ + auth_session_expire_time_seconds?: number; +}; + export type Body_auth_reset_forgot_password = { email: string; }; @@ -367,6 +398,21 @@ export type GetWorkflowDefinitionActivityInputs = { task?: ActionStatement | null; }; +export type GitSettingsRead = { + git_allowed_domains: Array<(string)>; + git_repo_url?: string | null; + git_repo_package_name?: string | null; +}; + +export type GitSettingsUpdate = { + /** + * Allowed git domains for authentication. + */ + git_allowed_domains?: Array<(string)>; + git_repo_url?: string | null; + git_repo_package_name?: string | null; +}; + export type HTTPValidationError = { detail?: Array; }; @@ -377,6 +423,23 @@ export type OAuth2AuthorizeResponse = { authorization_url: string; }; +/** + * Settings for OAuth authentication. + */ +export type OAuthSettingsRead = { + oauth_google_enabled: boolean; +}; + +/** + * Settings for OAuth authentication. + */ +export type OAuthSettingsUpdate = { + /** + * Whether OAuth is enabled. + */ + oauth_google_enabled?: boolean; +}; + export type OrgMemberRead = { user_id: string; first_name: string | null; @@ -705,6 +768,26 @@ export type SAMLDatabaseLoginResponse = { redirect_url: string; }; +export type SAMLSettingsRead = { + saml_enabled: boolean; + saml_enforced: boolean; + saml_idp_metadata_url?: string | null; + saml_sp_acs_url?: string | null; +}; + +export type SAMLSettingsUpdate = { + /** + * Whether SAML is enabled. + */ + saml_enabled?: boolean; + /** + * Whether SAML is enforced. If true, users can only use SAML to authenticate. Requires SAML to be enabled. + */ + saml_enforced?: boolean; + saml_idp_metadata_url?: string | null; + saml_sp_acs_url?: string | null; +}; + export type Schedule = { owner_id: string; created_at?: string; @@ -889,6 +972,12 @@ export type SessionRead = { user_email: string; }; +export type SettingRead = { + key: string; + value_type: ValueType; + value: unknown; +}; + export type TagCreate = { name: string; color?: string | null; @@ -1040,6 +1129,8 @@ export type ValidationError = { type: string; }; +export type ValueType = "json"; + export type WebhookResponse = { owner_id: string; created_at?: string; @@ -1708,6 +1799,8 @@ export type EditorListActionsData = { export type EditorListActionsResponse = Array; +export type RegistryRepositoriesReloadRegistryRepositoriesResponse = void; + export type RegistryRepositoriesSyncRegistryRepositoryData = { repositoryId: string; }; @@ -1774,6 +1867,44 @@ export type RegistryActionsDeleteRegistryActionData = { export type RegistryActionsDeleteRegistryActionResponse = void; +export type SettingsGetGitSettingsResponse = GitSettingsRead; + +export type SettingsUpdateGitSettingsData = { + requestBody: GitSettingsUpdate; +}; + +export type SettingsUpdateGitSettingsResponse = void; + +export type SettingsGetSamlSettingsResponse = SAMLSettingsRead; + +export type SettingsUpdateSamlSettingsData = { + requestBody: SAMLSettingsUpdate; +}; + +export type SettingsUpdateSamlSettingsResponse = void; + +export type SettingsGetAuthSettingsResponse = AuthSettingsRead; + +export type SettingsUpdateAuthSettingsData = { + requestBody: AuthSettingsUpdate; +}; + +export type SettingsUpdateAuthSettingsResponse = void; + +export type SettingsGetOauthSettingsResponse = OAuthSettingsRead; + +export type SettingsUpdateOauthSettingsData = { + requestBody: OAuthSettingsUpdate; +}; + +export type SettingsUpdateOauthSettingsResponse = void; + +export type SettingsListSettingsData = { + key?: Array<(string)> | null; +}; + +export type SettingsListSettingsResponse = Array; + export type UsersUsersCurrentUserResponse = UserRead; export type UsersUsersPatchCurrentUserData = { @@ -2747,6 +2878,16 @@ export type $OpenApiTs = { }; }; }; + '/registry/repos/reload': { + post: { + res: { + /** + * Successful Response + */ + 204: void; + }; + }; + }; '/registry/repos/{repository_id}/sync': { post: { req: RegistryRepositoriesSyncRegistryRepositoryData; @@ -2905,6 +3046,113 @@ export type $OpenApiTs = { }; }; }; + '/settings/git': { + get: { + res: { + /** + * Successful Response + */ + 200: GitSettingsRead; + }; + }; + patch: { + req: SettingsUpdateGitSettingsData; + res: { + /** + * Successful Response + */ + 204: void; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; + '/settings/saml': { + get: { + res: { + /** + * Successful Response + */ + 200: SAMLSettingsRead; + }; + }; + patch: { + req: SettingsUpdateSamlSettingsData; + res: { + /** + * Successful Response + */ + 204: void; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; + '/settings/auth': { + get: { + res: { + /** + * Successful Response + */ + 200: AuthSettingsRead; + }; + }; + patch: { + req: SettingsUpdateAuthSettingsData; + res: { + /** + * Successful Response + */ + 204: void; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; + '/settings/oauth': { + get: { + res: { + /** + * Successful Response + */ + 200: OAuthSettingsRead; + }; + }; + patch: { + req: SettingsUpdateOauthSettingsData; + res: { + /** + * Successful Response + */ + 204: void; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; + '/settings': { + get: { + req: SettingsListSettingsData; + res: { + /** + * Successful Response + */ + 200: Array; + /** + * Validation Error + */ + 422: HTTPValidationError; + }; + }; + }; '/users/me': { get: { res: { From 4d514f2d960230a7940c6af00f36f5ddfb091d1d Mon Sep 17 00:00:00 2001 From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com> Date: Thu, 9 Jan 2025 15:46:20 +0800 Subject: [PATCH 7/7] Update test --- tests/unit/test_organization_settings.py | 44 ++++++++++++++---------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/tests/unit/test_organization_settings.py b/tests/unit/test_organization_settings.py index 0a4537af5..5927877e4 100644 --- a/tests/unit/test_organization_settings.py +++ b/tests/unit/test_organization_settings.py @@ -4,6 +4,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from tracecat.auth.enums import AuthType +from tracecat.contexts import ctx_role from tracecat.settings.models import ( AuthSettingsUpdate, GitSettingsUpdate, @@ -169,7 +170,7 @@ async def test_delete_system_setting( @pytest.mark.anyio -async def test_delete_mortal_setting( +async def test_delete_non_system_setting( settings_service: SettingsService, create_params: SettingCreate, ) -> None: @@ -283,28 +284,33 @@ async def test_get_setting_shorthand( svc_admin_role: Role, ) -> None: """Test the get_setting shorthand function with and without roles.""" - # Create a test setting first - curr_session = settings_service.session + token = ctx_role.set(None) # type: ignore + assert ctx_role.get() is None, "Role should be cleared" + try: + # Create a test setting first + curr_session = settings_service.session - created_setting = await settings_service.create_org_setting(create_params) + created_setting = await settings_service.create_org_setting(create_params) - # Test with valid role (should return value) - value = await get_setting( - created_setting.key, role=svc_admin_role, session=curr_session - ) - assert value == create_params.value + # Test with valid role (should return value) + value = await get_setting( + created_setting.key, role=svc_admin_role, session=curr_session + ) + assert value == create_params.value - # Test with no role (should return None) - no_role_value = await get_setting( - created_setting.key, role=None, session=curr_session - ) - assert no_role_value is None + # Test with no role (should return None) + no_role_value = await get_setting( + created_setting.key, role=None, session=curr_session + ) + assert no_role_value is None - # Test retrieving non-existent setting - nonexistent_value = await get_setting( - "nonexistent-key", role=svc_admin_role, session=curr_session - ) - assert nonexistent_value is None + # Test retrieving non-existent setting + nonexistent_value = await get_setting( + "nonexistent-key", role=svc_admin_role, session=curr_session + ) + assert nonexistent_value is None + finally: + ctx_role.set(token) # type: ignore @pytest.mark.anyio