diff --git a/README.md b/README.md index 5907d50e..170e7b72 100644 --- a/README.md +++ b/README.md @@ -298,20 +298,28 @@ To override values on the front-end, modify these key-value pairs inside the `FR |---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------| | `ACCESS_TIME_LABELS` | Specifies the time access labels to use for dropdowns on the front end. Contains a JSON object of the format `{"NUM_SECONDS": "LABEL"}`. | `{"86400": "1 day", "604800": "1 week", "2592000": "1 month"}` | | `DEFAULT_ACCESS_TIME` | Specifies the default time access label to use for dropdowns on the front end. Contains a string with a number of seconds corresponding to a key in the access time labels. | `"86400"` | -| `NAME_VALIDATION_PATTERN` | Specifies the regex pattern to use for validating role, group, and tag names. Should include preceding `^` and trailing `$` but is not a regex literal so omit `/` at beginning and end of the pattern | `"^[a-zA-Z0-9-]*$"` | -| `NAME_VALIDATION_ERROR` | Specifies the error message to display when a name does not match the validation pattern. | `"Name must contain only letters, numbers, and underscores."` | +| `NAME_VALIDATION_PATTERN` | Specifies the regex pattern to use for validating role, group, and tag names. Should include preceding `^` and trailing `$` but is not a regex literal so omit `/` at beginning and end of the pattern | `"^[a-zA-Z0-9-]*$"` | +| `NAME_VALIDATION_ERROR` | Specifies the error message to display when a name does not match the validation pattern. | `"Name must contain only letters, numbers, and underscores."` | +| `APP_GROUP_NAME_PREFIX` | Specifies the prefix prepended to app group names. **Must match the `BACKEND` value.** | `"App-"` | +| `APP_NAME_GROUP_NAME_SEPARATOR` | Specifies the separator between the app name and the group name suffix within an app group name. **Must match the `BACKEND` value.** | `"-"` | +| `ROLE_GROUP_NAME_PREFIX` | Specifies the prefix prepended to role group names. **Must match the `BACKEND` value.** | `"Role-"` | The front-end config is loaded in [`vite.config.ts`](vite.config.ts). See [`src/config/loadAccessConfig.js`](src/config/loadAccessConfig.js) for more details. +> **Note:** Because the front-end config is injected at build time by Vite, you must restart the Vite dev server (or rebuild) after changing `config.default.json` or your override file for the new values to take effect. + #### Backend Configuration To override values on the back-end, modify these key-value pairs inside the `BACKEND` key in your custom config file. -| Name | Details | Example | -|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------| -| `NAME_VALIDATION_PATTERN` | PCRE regex used for validating role, group, and tag names. Should not explicitly declare pattern boundaries: depending on context, may be used with or without a preceding `^` and a trailing `$`. | `[A-Z][A-Za-z0-9-]*` | -| `NAME_VALIDATION_ERROR` | Error message to display when a name does not match the validation pattern. | `Name must start with a capital letter and contain only letters, numbers, and hypens.` | +| Name | Details | Example | +|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------| +| `NAME_VALIDATION_PATTERN` | PCRE regex used for validating role, group, and tag names. Should not explicitly declare pattern boundaries: depending on context, may be used with or without a preceding `^` and a trailing `$`. | `[A-Z][A-Za-z0-9-]*` | +| `NAME_VALIDATION_ERROR` | Error message to display when a name does not match the validation pattern. | `Name must start with a capital letter and contain only letters, numbers, and hypens.` | +| `APP_GROUP_NAME_PREFIX` | Prefix prepended to app group names. **Must match the `FRONTEND` value.** | `App-` | +| `APP_NAME_GROUP_NAME_SEPARATOR` | Separator between the app name and the group name suffix within an app group name. **Must match the `FRONTEND` value.** | `-` | +| `ROLE_GROUP_NAME_PREFIX` | Prefix prepended to role group names. **Must match the `FRONTEND` value.** | `Role-` | The back-end config is loaded in [`api/access_config.py`](api/access_config.py). diff --git a/api/access_config.py b/api/access_config.py index b7be842e..2d8935c1 100644 --- a/api/access_config.py +++ b/api/access_config.py @@ -9,6 +9,9 @@ BACKEND = "BACKEND" NAME_VALIDATION_PATTERN = "NAME_VALIDATION_PATTERN" NAME_VALIDATION_ERROR = "NAME_VALIDATION_ERROR" +APP_GROUP_NAME_PREFIX = "APP_GROUP_NAME_PREFIX" +APP_NAME_GROUP_NAME_SEPARATOR = "APP_NAME_GROUP_NAME_SEPARATOR" +ROLE_GROUP_NAME_PREFIX = "ROLE_GROUP_NAME_PREFIX" class UndefinedConfigKeyError(Exception): @@ -27,9 +30,19 @@ def __init__(self, error: str): class AccessConfig: - def __init__(self, name_pattern: str, name_validation_error: str): + def __init__( + self, + name_pattern: str, + name_validation_error: str, + app_group_name_prefix: str, + app_name_group_name_separator: str, + role_group_name_prefix: str, + ): self.name_pattern = name_pattern self.name_validation_error = name_validation_error + self.app_group_name_prefix = app_group_name_prefix + self.app_name_group_name_separator = app_name_group_name_separator + self.role_group_name_prefix = role_group_name_prefix def _get_config_value(config: dict[str, Any], key: str) -> Any: @@ -76,10 +89,16 @@ def _load_access_config() -> AccessConfig: name_pattern = _get_config_value(config, NAME_VALIDATION_PATTERN) name_validation_error = _get_config_value(config, NAME_VALIDATION_ERROR) + app_group_name_prefix = _get_config_value(config, APP_GROUP_NAME_PREFIX) + app_name_group_name_separator = _get_config_value(config, APP_NAME_GROUP_NAME_SEPARATOR) + role_group_name_prefix = _get_config_value(config, ROLE_GROUP_NAME_PREFIX) return AccessConfig( name_pattern=name_pattern, name_validation_error=name_validation_error, + app_group_name_prefix=app_group_name_prefix, + app_name_group_name_separator=app_name_group_name_separator, + role_group_name_prefix=role_group_name_prefix, ) diff --git a/api/models/core_models.py b/api/models/core_models.py index 85a57293..76dcfe64 100644 --- a/api/models/core_models.py +++ b/api/models/core_models.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import StrEnum -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, ClassVar, Dict, List, Optional from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, validates @@ -8,6 +8,7 @@ from sqlalchemy_json import mutable_json_type from api import config +from api.access_config import get_access_config from api.extensions import db @@ -554,7 +555,7 @@ def validate_group(self, key: str, group: OktaGroup) -> OktaGroup: class RoleGroup(OktaGroup): - ROLE_GROUP_NAME_PREFIX = "Role-" + ROLE_GROUP_NAME_PREFIX: ClassVar[str] # set from config at module load, see bottom of file __tablename__ = "role_group" id: Mapped[str] = mapped_column(db.Unicode(50), db.ForeignKey("okta_group.id"), primary_key=True) @@ -602,8 +603,8 @@ class RoleGroup(OktaGroup): class AppGroup(OktaGroup): - APP_GROUP_NAME_PREFIX = "App-" - APP_NAME_GROUP_NAME_SEPARATOR = "-" + APP_GROUP_NAME_PREFIX: ClassVar[str] # set from config at module load, see bottom of file + APP_NAME_GROUP_NAME_SEPARATOR: ClassVar[str] # set from config at module load, see bottom of file APP_OWNERS_GROUP_NAME_SUFFIX = "Owners" __tablename__ = "app_group" @@ -1274,3 +1275,15 @@ class AppTagMap(db.Model): lazy="raise_on_sql", innerjoin=True, ) + + +# Initialize group name prefix class constants from config so they stay in sync +# with the frontend (config/config.default.json) and can be overridden via ACCESS_CONFIG_FILE. +def _init_group_name_prefixes() -> None: + cfg = get_access_config() + RoleGroup.ROLE_GROUP_NAME_PREFIX = cfg.role_group_name_prefix + AppGroup.APP_GROUP_NAME_PREFIX = cfg.app_group_name_prefix + AppGroup.APP_NAME_GROUP_NAME_SEPARATOR = cfg.app_name_group_name_separator + + +_init_group_name_prefixes() diff --git a/config/config.default.json b/config/config.default.json index 3a075a4e..524d9553 100644 --- a/config/config.default.json +++ b/config/config.default.json @@ -11,10 +11,16 @@ }, "DEFAULT_ACCESS_TIME": "1209600", "NAME_VALIDATION_PATTERN": "^[A-Z][A-Za-z0-9\\-]*$", - "NAME_VALIDATION_ERROR": "Name must start capitalized and contain only alphanumeric characters or hyphens." + "NAME_VALIDATION_ERROR": "Name must start capitalized and contain only alphanumeric characters or hyphens.", + "APP_GROUP_NAME_PREFIX": "App-", + "APP_NAME_GROUP_NAME_SEPARATOR": "-", + "ROLE_GROUP_NAME_PREFIX": "Role-" }, "BACKEND": { "NAME_VALIDATION_PATTERN": "[A-Z][A-Za-z0-9-]*", - "NAME_VALIDATION_ERROR": "name must start capitalized and contain only alphanumeric characters or hyphens." + "NAME_VALIDATION_ERROR": "name must start capitalized and contain only alphanumeric characters or hyphens.", + "APP_GROUP_NAME_PREFIX": "App-", + "APP_NAME_GROUP_NAME_SEPARATOR": "-", + "ROLE_GROUP_NAME_PREFIX": "Role-" } } diff --git a/src/config/accessConfig.ts b/src/config/accessConfig.ts index 4b7dfe19..2f3d7dd0 100644 --- a/src/config/accessConfig.ts +++ b/src/config/accessConfig.ts @@ -3,6 +3,9 @@ export interface AccessConfig { DEFAULT_ACCESS_TIME: string; NAME_VALIDATION_PATTERN: string; NAME_VALIDATION_ERROR: string; + APP_GROUP_NAME_PREFIX: string; + APP_NAME_GROUP_NAME_SEPARATOR: string; + ROLE_GROUP_NAME_PREFIX: string; } // use the globally-injected ACCESS_CONFIG from src/globals.d.ts, typed to AccessConfig interface diff --git a/src/config/loadAccessConfig.js b/src/config/loadAccessConfig.js index d72bd767..d7f03c62 100644 --- a/src/config/loadAccessConfig.js +++ b/src/config/loadAccessConfig.js @@ -11,6 +11,9 @@ const DEFAULT_ACCESS_TIME = 'DEFAULT_ACCESS_TIME'; const FRONTEND = 'FRONTEND'; const NAME_VALIDATION_PATTERN = 'NAME_VALIDATION_PATTERN'; const NAME_VALIDATION_ERROR = 'NAME_VALIDATION_ERROR'; +const APP_GROUP_NAME_PREFIX = 'APP_GROUP_NAME_PREFIX'; +const APP_NAME_GROUP_NAME_SEPARATOR = 'APP_NAME_GROUP_NAME_SEPARATOR'; +const ROLE_GROUP_NAME_PREFIX = 'ROLE_GROUP_NAME_PREFIX'; class UndefinedConfigError extends Error { constructor(key, obj) { diff --git a/src/pages/group_requests/Create.tsx b/src/pages/group_requests/Create.tsx index fb1c1045..5f307c84 100644 --- a/src/pages/group_requests/Create.tsx +++ b/src/pages/group_requests/Create.tsx @@ -48,9 +48,9 @@ const GROUP_TYPE_ID_TO_LABELS: Record = { const GROUP_TYPE_OPTIONS = Object.entries(GROUP_TYPE_ID_TO_LABELS).map(([id, label]) => ({id, label})); -const APP_GROUP_PREFIX = 'App-'; -const APP_NAME_APP_GROUP_SEPARATOR = '-'; -const ROLE_GROUP_PREFIX = 'Role-'; +const APP_GROUP_PREFIX = accessConfig.APP_GROUP_NAME_PREFIX; +const APP_NAME_APP_GROUP_SEPARATOR = accessConfig.APP_NAME_GROUP_NAME_SEPARATOR; +const ROLE_GROUP_PREFIX = accessConfig.ROLE_GROUP_NAME_PREFIX; const RFC822_FORMAT = 'ddd, DD MMM YYYY HH:mm:ss ZZ'; diff --git a/src/pages/group_requests/Read.tsx b/src/pages/group_requests/Read.tsx index eb624652..34e7ba60 100644 --- a/src/pages/group_requests/Read.tsx +++ b/src/pages/group_requests/Read.tsx @@ -81,9 +81,9 @@ const UNTIL_JUST_NUMERIC_ID_TO_LABELS: Record = Object.fromEntri Object.entries(UNTIL_ID_TO_LABELS).filter(([key]) => !isNaN(Number(key))), ); -const APP_GROUP_PREFIX = 'App-'; -const APP_NAME_APP_GROUP_SEPARATOR = '-'; -const ROLE_GROUP_PREFIX = 'Role-'; +const APP_GROUP_PREFIX = accessConfig.APP_GROUP_NAME_PREFIX; +const APP_NAME_APP_GROUP_SEPARATOR = accessConfig.APP_NAME_GROUP_NAME_SEPARATOR; +const ROLE_GROUP_PREFIX = accessConfig.ROLE_GROUP_NAME_PREFIX; const RFC822_FORMAT = 'ddd, DD MMM YYYY HH:mm:ss ZZ'; diff --git a/src/pages/groups/CreateUpdate.tsx b/src/pages/groups/CreateUpdate.tsx index 246c4506..8bc10107 100644 --- a/src/pages/groups/CreateUpdate.tsx +++ b/src/pages/groups/CreateUpdate.tsx @@ -76,9 +76,9 @@ const GROUP_TYPE_OPTIONS = Object.entries(GROUP_TYPE_ID_TO_LABELS).map(([id, lab label: label, })); -const APP_GROUP_PREFIX = 'App-'; -const APP_NAME_APP_GROUP_SEPARATOR = '-'; -const ROLE_GROUP_PREFIX = 'Role-'; +const APP_GROUP_PREFIX = accessConfig.APP_GROUP_NAME_PREFIX; +const APP_NAME_APP_GROUP_SEPARATOR = accessConfig.APP_NAME_GROUP_NAME_SEPARATOR; +const ROLE_GROUP_PREFIX = accessConfig.ROLE_GROUP_NAME_PREFIX; function GroupDialog(props: GroupDialogProps) { const navigate = useNavigate(); diff --git a/tests/test_access_config.py b/tests/test_access_config.py index 184edada..16cdc907 100644 --- a/tests/test_access_config.py +++ b/tests/test_access_config.py @@ -17,6 +17,9 @@ _merge_override_config, NAME_VALIDATION_PATTERN, NAME_VALIDATION_ERROR, + APP_GROUP_NAME_PREFIX, + APP_NAME_GROUP_NAME_SEPARATOR, + ROLE_GROUP_NAME_PREFIX, ConfigValidationError, _validate_override_config, ) @@ -29,6 +32,9 @@ def mock_load_default_config() -> Generator[Any, Any, Any]: return_value={ NAME_VALIDATION_PATTERN: "name_pattern", NAME_VALIDATION_ERROR: "name_error", + APP_GROUP_NAME_PREFIX: "App-", + APP_NAME_GROUP_NAME_SEPARATOR: "-", + ROLE_GROUP_NAME_PREFIX: "Role-", }, ): yield @@ -41,6 +47,9 @@ def mock_merge_override_config() -> Generator[Any, Any, Any]: { NAME_VALIDATION_PATTERN: "override_name_pattern", NAME_VALIDATION_ERROR: "override_name_error", + APP_GROUP_NAME_PREFIX: "Override-", + APP_NAME_GROUP_NAME_SEPARATOR: "_", + ROLE_GROUP_NAME_PREFIX: "OverrideRole-", } ) yield mock_merge @@ -51,6 +60,9 @@ def test_load_config_default(mock_load_default_config: None) -> None: assert isinstance(config, AccessConfig) assert config.name_pattern == "name_pattern" assert config.name_validation_error == "name_error" + assert config.app_group_name_prefix == "App-" + assert config.app_name_group_name_separator == "-" + assert config.role_group_name_prefix == "Role-" def test_load_config_with_override(mock_load_default_config: None, mock_merge_override_config: None) -> None: @@ -58,6 +70,9 @@ def test_load_config_with_override(mock_load_default_config: None, mock_merge_ov assert isinstance(config, AccessConfig) assert config.name_pattern == "override_name_pattern" assert config.name_validation_error == "override_name_error" + assert config.app_group_name_prefix == "Override-" + assert config.app_name_group_name_separator == "_" + assert config.role_group_name_prefix == "OverrideRole-" def test_load_default_config() -> None: