Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make the default time durations configurable on User Form Interface #212

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.production.example
rktoomey marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ REACT_APP_API_SERVER_URL=""
FLASK_SENTRY_DSN=https://<key>@sentry.io/<project>
REACT_SENTRY_DSN=https://<key>@sentry.io/<project>
CLOUDFLARE_TEAM_DOMAIN=<CLOUDFLARE_ACCESS_TEAM_DOMAIN>
CLOUDFLARE_APPLICATION_AUDIENCE=<CLOUFLARE_ACCESS_AUDIENCE_TAG>
CLOUDFLARE_APPLICATION_AUDIENCE=<CLOUDFLARE_ACCESS_AUDIENCE_TAG>
SECRET_KEY=<YOUR_SECRET_KEY>
OIDC_CLIENT_SECRETS=<YOUR_CLIENT_SECRETS>
OIDC_CLIENT_SECRETS=<YOUR_CLIENT_SECRETS>
ACCESS_CONFIG_FILE=<NAME_OF_FILE_IN_CONFIG_DIR>
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -497,3 +497,10 @@ $RECYCLE.BIN/
*.lnk

# End of https://www.toptal.com/developers/gitignore/api/macos,windows,visualstudiocode,jetbrains+all,node,python,flask

# try to prevent override config files from being accidentally committed
config/config.production.json
config/config.staging.json
config/config.development.json
config/config.test.json
config/config.override.json
15 changes: 9 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ ARG PUSH_SENTRY_RELEASE="false"
FROM node:22-alpine AS build-step
ARG SENTRY_RELEASE=""
WORKDIR /app
ENV PATH /app/node_modules/.bin:$PATH
ENV PATH=/app/node_modules/.bin:$PATH
COPY craco.config.js package.json package-lock.json tsconfig.json tsconfig.paths.json .env.production* ./
COPY ./src ./src
COPY ./public ./public
rktoomey marked this conversation as resolved.
Show resolved Hide resolved
COPY ./config ./config

RUN npm install
RUN touch .env.production
ENV REACT_APP_SENTRY_RELEASE $SENTRY_RELEASE
ENV REACT_APP_API_SERVER_URL ""
ENV REACT_APP_SENTRY_RELEASE=$SENTRY_RELEASE
ENV REACT_APP_API_SERVER_URL=""
RUN npm run build

# Optional build step #2: upload the source maps by pushing a release to sentry
Expand All @@ -38,6 +40,7 @@ RUN rm ./build/static/js/*.map
RUN mkdir ./api && mkdir ./migrations
COPY requirements.txt api/ ./api/
COPY migrations/ ./migrations/
COPY ./config ./config
RUN pip install -r ./api/requirements.txt

# Build an image that includes the optional sentry release push build step
Expand All @@ -48,9 +51,9 @@ COPY --from=sentry /app/sentry ./sentry
# Choose whether to include the sentry release push build step or not
FROM ${PUSH_SENTRY_RELEASE}

ENV FLASK_ENV production
ENV FLASK_APP api.app:create_app
ENV SENTRY_RELEASE $SENTRY_RELEASE
ENV FLASK_ENV=production
ENV FLASK_APP=api.app:create_app
ENV SENTRY_RELEASE=$SENTRY_RELEASE

EXPOSE 3000

Expand Down
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,76 @@ If you are using Cloudflare Access, ensure that you configure `CLOUDFLARE_TEAM_D

Else, if you are using a generic OIDC identity provider (such as Okta), then you should configure `SECRET_KEY` and `OIDC_CLIENT_SECRETS`. `CLOUDFLARE_TEAM_DOMAIN` and `CLOUDFLARE_APPLICATION_AUDIENCE` do not need to be set and can be removed from your env file. Make sure to also mount your `client-secrets.json` file to the container if you don't have it inline.

### Access application configuration

_All front-end and back-end configuration overrides are **optional**._

The default config for the application is at [`config/config.default.json`](config/config.default.json).

The file is structured with two keys, `FRONTEND` and `BACKEND`, which contain the configuration overrides for the
front-end and back-end respectively.

If you want to override either front-end or back-end values, create your own config file based on
[`config/config.default.json`](config/config.default.json). Any values that you don't override will fall back to
the values in the default config.

To use your custom config file, set the `ACCESS_CONFIG_FILE` environment variable to the name of your config
override file in the project-level `config` directory.

### Sample Usage

To override environment variables, create an override config file in the `config` directory. (You can name
this file whatever you want because the name of the file is specified by your `ACCESS_CONFIG_FILE` environment
variable.)

For example, if you want to set the default access time to 5 days in production, you might create a file named
`config.production.json` in the `config` directory:

```json
{
"FRONTEND": {
"DEFAULT_ACCESS_TIME": "432000"
}
}
```

Then, in your `.env.production` file, set the `ACCESS_CONFIG_FILE` environment variable to the name of your
config file:

```
ACCESS_CONFIG_FILE=config.production.json
```

This tells the application to use `config.production.json` for configuration overrides.

#### Frontend Configuration

To override values on the front-end, modify these key-value pairs inside the `FRONTEND` key in your custom config file.

| Name | Details | Example |
|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------|
| `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."` |

The front-end config is loaded in [`craco.config.js`](craco.config.js). See
[`src/config/loadAccessConfig.js`](src/config/loadAccessConfig.js) for more details.

#### 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.` |

The back-end config is loaded in [`api/access_config.py`](api/access_config.py).

See [`api/views/schemas/core_schemas.py`](api/views/schemas/core_schemas.py) for details about how the pattern override
supplied here will be used.

#### Database Setup

After `docker compose up --build`, you can run the following commands to setup the database:
Expand Down
96 changes: 96 additions & 0 deletions api/access_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import json
import logging
import os
from typing import Any

logger = logging.getLogger(__name__)

# Define constants for AccessConfig JSON keys
BACKEND = "BACKEND"
NAME_VALIDATION_PATTERN = "NAME_VALIDATION_PATTERN"
NAME_VALIDATION_ERROR = "NAME_VALIDATION_ERROR"


class UndefinedConfigKeyError(Exception):
def __init__(self, key: str, config: dict[str, Any]):
super().__init__(f"'{key}' is not a defined config value in: {sorted(config.keys())}")


class ConfigFileNotFoundError(Exception):
def __init__(self, file_path: str):
super().__init__(f"Config override file not found: {file_path}")


class ConfigValidationError(Exception):
def __init__(self, error: str):
super().__init__(f"Config validation failed: {error}")


class AccessConfig:
def __init__(self, name_pattern: str, name_validation_error: str):
self.name_pattern = name_pattern
self.name_validation_error = name_validation_error


def _get_config_value(config: dict[str, Any], key: str) -> Any:
if key in config:
return config[key]
else:
raise UndefinedConfigKeyError(key, config)


def _validate_override_config(config: dict[str, Any]) -> None:
if (NAME_VALIDATION_PATTERN in config) != (NAME_VALIDATION_ERROR in config):
raise ConfigValidationError(
f"If either {NAME_VALIDATION_PATTERN} or {NAME_VALIDATION_ERROR} is present, the other must also be present."
)


def _merge_override_config(config: dict[str, Any], top_level_dir: str) -> None:
access_config_file = os.getenv("ACCESS_CONFIG_FILE")
if access_config_file:
override_config_path = os.path.join(top_level_dir, "config", access_config_file)
if os.path.exists(override_config_path):
logger.debug(f"Loading access config override from {override_config_path}")
with open(override_config_path, "r") as f:
override_config = json.load(f).get(BACKEND, {})
_validate_override_config(override_config)
config.update(override_config)
else:
raise ConfigFileNotFoundError(str(override_config_path))


def _load_default_config(top_level_dir: str) -> dict[str, Any]:
default_config_path = os.path.join(top_level_dir, "config", "config.default.json")
if not os.path.exists(default_config_path):
raise ConfigFileNotFoundError(str(default_config_path))
with open(default_config_path, "r") as f:
config = json.load(f).get(BACKEND, {})
return config


def _load_access_config() -> AccessConfig:
top_level_dir = os.path.dirname(os.path.dirname(__file__))
config = _load_default_config(top_level_dir)
_merge_override_config(config, top_level_dir)

name_pattern = _get_config_value(config, NAME_VALIDATION_PATTERN)
name_validation_error = _get_config_value(config, NAME_VALIDATION_ERROR)

return AccessConfig(
name_pattern=name_pattern,
name_validation_error=name_validation_error,
)


_ACCESS_CONFIG = None


def get_access_config() -> AccessConfig:
global _ACCESS_CONFIG
if _ACCESS_CONFIG is None:
_ACCESS_CONFIG = _load_access_config()
return _ACCESS_CONFIG


__all__ = ["get_access_config", "AccessConfig"]
34 changes: 15 additions & 19 deletions api/views/schemas/core_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from marshmallow.schema import SchemaMeta, SchemaOpts
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field
from sqlalchemy.orm import Session

from api.access_config import get_access_config
from api.extensions import db
from api.models import (
AccessRequest,
Expand All @@ -22,6 +22,8 @@
Tag,
)

access_config = get_access_config()


# See https://stackoverflow.com/a/58646612
class OktaUserGroupMemberSchema(SQLAlchemyAutoSchema):
Expand Down Expand Up @@ -246,9 +248,8 @@ class OktaGroupSchema(SQLAlchemyAutoSchema):
validate=validate.And(
validate.Length(min=1, max=255),
validate.Regexp(
"^[A-Z][A-Za-z0-9-]*$",
error="Group name must start capitalized and contain only alphanumeric characters or hyphens. "
"Regex to match: /{regex}/",
f"^{access_config.name_pattern}$",
error=f"Group {access_config.name_validation_error} Regex to match: /{{regex}}/",
),
),
)
Expand Down Expand Up @@ -618,9 +619,8 @@ class RoleGroupSchema(SQLAlchemyAutoSchema):
validate=validate.And(
validate.Length(min=1, max=255),
validate.Regexp(
f"^{RoleGroup.ROLE_GROUP_NAME_PREFIX}[A-Z][A-Za-z0-9-]*$",
error="Role name must start capitalized and contain only alphanumeric characters or hyphens. "
"Regex to match: /{regex}/",
f"^{RoleGroup.ROLE_GROUP_NAME_PREFIX}{access_config.name_pattern}$",
error=f"Role {access_config.name_validation_error} Regex to match: /{{regex}}/",
),
),
)
Expand Down Expand Up @@ -838,9 +838,8 @@ class AppGroupSchema(SQLAlchemyAutoSchema):
validate=validate.And(
validate.Length(min=1, max=255),
validate.Regexp(
f"^{AppGroup.APP_GROUP_NAME_PREFIX}[A-Z][A-Za-z0-9-]*{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}[A-Z][A-Za-z0-9-]*$",
error="Group name must start capitalized and contain only alphanumeric characters or hyphens. "
"Regex to match: /{regex}/",
f"^{AppGroup.APP_GROUP_NAME_PREFIX}{access_config.name_pattern}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}{access_config.name_pattern}$",
error=f"Group {access_config.name_validation_error} Regex to match: /{{regex}}/",
),
),
)
Expand Down Expand Up @@ -1130,9 +1129,8 @@ class InitialAppGroupSchema(Schema):
validate=validate.And(
validate.Length(min=1, max=255),
validate.Regexp(
f"^{AppGroup.APP_GROUP_NAME_PREFIX}[A-Z][A-Za-z0-9-]*{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}[A-Z][A-Za-z0-9-]*$",
error="Group name must start capitalized and contain only alphanumeric characters or hyphens. "
"Regex to match: /{regex}/",
f"^{AppGroup.APP_GROUP_NAME_PREFIX}{access_config.name_pattern}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}{access_config.name_pattern}$",
error=f"Group {access_config.name_validation_error} Regex to match: /{{regex}}/",
),
),
)
Expand All @@ -1145,9 +1143,8 @@ class AppSchema(SQLAlchemyAutoSchema):
validate=validate.And(
validate.Length(min=1, max=255),
validate.Regexp(
"^[A-Z][A-Za-z0-9-]*$",
error="App name must start capitalized and contain only alphanumeric characters or hyphens. "
"Regex to match: /{regex}/",
f"^{access_config.name_pattern}$",
error=f"App {access_config.name_validation_error} Regex to match: /{{regex}}/",
),
),
)
Expand Down Expand Up @@ -1460,9 +1457,8 @@ class TagSchema(SQLAlchemyAutoSchema):
validate=validate.And(
validate.Length(min=1, max=255),
validate.Regexp(
"^[A-Z][A-Za-z0-9-]*$",
error="Tag name must start capitalized and contain only alphanumeric characters or hyphens. "
"Regex to match: /{regex}/",
f"^{access_config.name_pattern}$",
error=f"Tag {access_config.name_validation_error} Regex to match: /{{regex}}/",
),
),
)
Expand Down
20 changes: 20 additions & 0 deletions config/config.default.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"FRONTEND": {
"ACCESS_TIME_LABELS": {
"43200": "12 Hours",
"432000": "5 Days",
"1209600": "Two Weeks",
"2592000": "30 Days",
"7776000": "90 Days",
"indefinite": "Indefinite",
"custom": "Custom"
},
"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."
},
"BACKEND": {
"NAME_VALIDATION_PATTERN": "[A-Z][A-Za-z0-9-]*",
"NAME_VALIDATION_ERROR": "name must start capitalized and contain only alphanumeric characters or hyphens."
}
}
10 changes: 10 additions & 0 deletions craco.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
const CracoAlias = require('react-app-alias');
const path = require('path');
const webpack = require('webpack');
const {loadAccessConfig} = require('./src/config/loadAccessConfig');

const accessConfig = loadAccessConfig();

module.exports = {
plugins: [
Expand All @@ -15,5 +20,10 @@ module.exports = {
alias: {
'@mui/styled-engine': '@mui/styled-engine-sc',
},
plugins: [
new webpack.DefinePlugin({
ACCESS_CONFIG: accessConfig,
}),
],
},
};
Loading
Loading