Skip to content

Commit d677642

Browse files
Make the default time durations configurable on User Form Interface (#212)
Co-authored-by: Jonathan Le <[email protected]>
1 parent 386e494 commit d677642

25 files changed

+993
-640
lines changed

.env.production.example

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ REACT_APP_API_SERVER_URL=""
1010
FLASK_SENTRY_DSN=https://<key>@sentry.io/<project>
1111
REACT_SENTRY_DSN=https://<key>@sentry.io/<project>
1212
CLOUDFLARE_TEAM_DOMAIN=<CLOUDFLARE_ACCESS_TEAM_DOMAIN>
13-
CLOUDFLARE_APPLICATION_AUDIENCE=<CLOUFLARE_ACCESS_AUDIENCE_TAG>
13+
CLOUDFLARE_APPLICATION_AUDIENCE=<CLOUDFLARE_ACCESS_AUDIENCE_TAG>
1414
SECRET_KEY=<YOUR_SECRET_KEY>
15-
OIDC_CLIENT_SECRETS=<YOUR_CLIENT_SECRETS>
15+
OIDC_CLIENT_SECRETS=<YOUR_CLIENT_SECRETS>
16+
ACCESS_CONFIG_FILE=<NAME_OF_FILE_IN_CONFIG_DIR>

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,3 +497,10 @@ $RECYCLE.BIN/
497497
*.lnk
498498

499499
# End of https://www.toptal.com/developers/gitignore/api/macos,windows,visualstudiocode,jetbrains+all,node,python,flask
500+
501+
# try to prevent override config files from being accidentally committed
502+
config/config.production.json
503+
config/config.staging.json
504+
config/config.development.json
505+
config/config.test.json
506+
config/config.override.json

Dockerfile

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ ARG PUSH_SENTRY_RELEASE="false"
66
FROM node:22-alpine AS build-step
77
ARG SENTRY_RELEASE=""
88
WORKDIR /app
9-
ENV PATH /app/node_modules/.bin:$PATH
9+
ENV PATH=/app/node_modules/.bin:$PATH
1010
COPY craco.config.js package.json package-lock.json tsconfig.json tsconfig.paths.json .env.production* ./
1111
COPY ./src ./src
1212
COPY ./public ./public
13+
COPY ./config ./config
14+
1315
RUN npm install
1416
RUN touch .env.production
15-
ENV REACT_APP_SENTRY_RELEASE $SENTRY_RELEASE
16-
ENV REACT_APP_API_SERVER_URL ""
17+
ENV REACT_APP_SENTRY_RELEASE=$SENTRY_RELEASE
18+
ENV REACT_APP_API_SERVER_URL=""
1719
RUN npm run build
1820

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

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

51-
ENV FLASK_ENV production
52-
ENV FLASK_APP api.app:create_app
53-
ENV SENTRY_RELEASE $SENTRY_RELEASE
54+
ENV FLASK_ENV=production
55+
ENV FLASK_APP=api.app:create_app
56+
ENV SENTRY_RELEASE=$SENTRY_RELEASE
5457

5558
EXPOSE 3000
5659

README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,76 @@ If you are using Cloudflare Access, ensure that you configure `CLOUDFLARE_TEAM_D
248248

249249
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.
250250

251+
### Access application configuration
252+
253+
_All front-end and back-end configuration overrides are **optional**._
254+
255+
The default config for the application is at [`config/config.default.json`](config/config.default.json).
256+
257+
The file is structured with two keys, `FRONTEND` and `BACKEND`, which contain the configuration overrides for the
258+
front-end and back-end respectively.
259+
260+
If you want to override either front-end or back-end values, create your own config file based on
261+
[`config/config.default.json`](config/config.default.json). Any values that you don't override will fall back to
262+
the values in the default config.
263+
264+
To use your custom config file, set the `ACCESS_CONFIG_FILE` environment variable to the name of your config
265+
override file in the project-level `config` directory.
266+
267+
### Sample Usage
268+
269+
To override environment variables, create an override config file in the `config` directory. (You can name
270+
this file whatever you want because the name of the file is specified by your `ACCESS_CONFIG_FILE` environment
271+
variable.)
272+
273+
For example, if you want to set the default access time to 5 days in production, you might create a file named
274+
`config.production.json` in the `config` directory:
275+
276+
```json
277+
{
278+
"FRONTEND": {
279+
"DEFAULT_ACCESS_TIME": "432000"
280+
}
281+
}
282+
```
283+
284+
Then, in your `.env.production` file, set the `ACCESS_CONFIG_FILE` environment variable to the name of your
285+
config file:
286+
287+
```
288+
ACCESS_CONFIG_FILE=config.production.json
289+
```
290+
291+
This tells the application to use `config.production.json` for configuration overrides.
292+
293+
#### Frontend Configuration
294+
295+
To override values on the front-end, modify these key-value pairs inside the `FRONTEND` key in your custom config file.
296+
297+
| Name | Details | Example |
298+
|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------|
299+
| `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"}` |
300+
| `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"` |
301+
| `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-]*$"` |
302+
| `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."` |
303+
304+
The front-end config is loaded in [`craco.config.js`](craco.config.js). See
305+
[`src/config/loadAccessConfig.js`](src/config/loadAccessConfig.js) for more details.
306+
307+
#### Backend Configuration
308+
309+
To override values on the back-end, modify these key-value pairs inside the `BACKEND` key in your custom config file.
310+
311+
| Name | Details | Example |
312+
|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------|
313+
| `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-]*` |
314+
| `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.` |
315+
316+
The back-end config is loaded in [`api/access_config.py`](api/access_config.py).
317+
318+
See [`api/views/schemas/core_schemas.py`](api/views/schemas/core_schemas.py) for details about how the pattern override
319+
supplied here will be used.
320+
251321
#### Database Setup
252322

253323
After `docker compose up --build`, you can run the following commands to setup the database:

api/access_config.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import json
2+
import logging
3+
import os
4+
from typing import Any
5+
6+
logger = logging.getLogger(__name__)
7+
8+
# Define constants for AccessConfig JSON keys
9+
BACKEND = "BACKEND"
10+
NAME_VALIDATION_PATTERN = "NAME_VALIDATION_PATTERN"
11+
NAME_VALIDATION_ERROR = "NAME_VALIDATION_ERROR"
12+
13+
14+
class UndefinedConfigKeyError(Exception):
15+
def __init__(self, key: str, config: dict[str, Any]):
16+
super().__init__(f"'{key}' is not a defined config value in: {sorted(config.keys())}")
17+
18+
19+
class ConfigFileNotFoundError(Exception):
20+
def __init__(self, file_path: str):
21+
super().__init__(f"Config override file not found: {file_path}")
22+
23+
24+
class ConfigValidationError(Exception):
25+
def __init__(self, error: str):
26+
super().__init__(f"Config validation failed: {error}")
27+
28+
29+
class AccessConfig:
30+
def __init__(self, name_pattern: str, name_validation_error: str):
31+
self.name_pattern = name_pattern
32+
self.name_validation_error = name_validation_error
33+
34+
35+
def _get_config_value(config: dict[str, Any], key: str) -> Any:
36+
if key in config:
37+
return config[key]
38+
else:
39+
raise UndefinedConfigKeyError(key, config)
40+
41+
42+
def _validate_override_config(config: dict[str, Any]) -> None:
43+
if (NAME_VALIDATION_PATTERN in config) != (NAME_VALIDATION_ERROR in config):
44+
raise ConfigValidationError(
45+
f"If either {NAME_VALIDATION_PATTERN} or {NAME_VALIDATION_ERROR} is present, the other must also be present."
46+
)
47+
48+
49+
def _merge_override_config(config: dict[str, Any], top_level_dir: str) -> None:
50+
access_config_file = os.getenv("ACCESS_CONFIG_FILE")
51+
if access_config_file:
52+
override_config_path = os.path.join(top_level_dir, "config", access_config_file)
53+
if os.path.exists(override_config_path):
54+
logger.debug(f"Loading access config override from {override_config_path}")
55+
with open(override_config_path, "r") as f:
56+
override_config = json.load(f).get(BACKEND, {})
57+
_validate_override_config(override_config)
58+
config.update(override_config)
59+
else:
60+
raise ConfigFileNotFoundError(str(override_config_path))
61+
62+
63+
def _load_default_config(top_level_dir: str) -> dict[str, Any]:
64+
default_config_path = os.path.join(top_level_dir, "config", "config.default.json")
65+
if not os.path.exists(default_config_path):
66+
raise ConfigFileNotFoundError(str(default_config_path))
67+
with open(default_config_path, "r") as f:
68+
config = json.load(f).get(BACKEND, {})
69+
return config
70+
71+
72+
def _load_access_config() -> AccessConfig:
73+
top_level_dir = os.path.dirname(os.path.dirname(__file__))
74+
config = _load_default_config(top_level_dir)
75+
_merge_override_config(config, top_level_dir)
76+
77+
name_pattern = _get_config_value(config, NAME_VALIDATION_PATTERN)
78+
name_validation_error = _get_config_value(config, NAME_VALIDATION_ERROR)
79+
80+
return AccessConfig(
81+
name_pattern=name_pattern,
82+
name_validation_error=name_validation_error,
83+
)
84+
85+
86+
_ACCESS_CONFIG = None
87+
88+
89+
def get_access_config() -> AccessConfig:
90+
global _ACCESS_CONFIG
91+
if _ACCESS_CONFIG is None:
92+
_ACCESS_CONFIG = _load_access_config()
93+
return _ACCESS_CONFIG
94+
95+
96+
__all__ = ["get_access_config", "AccessConfig"]

api/views/schemas/core_schemas.py

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from marshmallow.schema import SchemaMeta, SchemaOpts
66
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field
77
from sqlalchemy.orm import Session
8-
8+
from api.access_config import get_access_config
99
from api.extensions import db
1010
from api.models import (
1111
AccessRequest,
@@ -22,6 +22,8 @@
2222
Tag,
2323
)
2424

25+
access_config = get_access_config()
26+
2527

2628
# See https://stackoverflow.com/a/58646612
2729
class OktaUserGroupMemberSchema(SQLAlchemyAutoSchema):
@@ -246,9 +248,8 @@ class OktaGroupSchema(SQLAlchemyAutoSchema):
246248
validate=validate.And(
247249
validate.Length(min=1, max=255),
248250
validate.Regexp(
249-
"^[A-Z][A-Za-z0-9-]*$",
250-
error="Group name must start capitalized and contain only alphanumeric characters or hyphens. "
251-
"Regex to match: /{regex}/",
251+
f"^{access_config.name_pattern}$",
252+
error=f"Group {access_config.name_validation_error} Regex to match: /{{regex}}/",
252253
),
253254
),
254255
)
@@ -618,9 +619,8 @@ class RoleGroupSchema(SQLAlchemyAutoSchema):
618619
validate=validate.And(
619620
validate.Length(min=1, max=255),
620621
validate.Regexp(
621-
f"^{RoleGroup.ROLE_GROUP_NAME_PREFIX}[A-Z][A-Za-z0-9-]*$",
622-
error="Role name must start capitalized and contain only alphanumeric characters or hyphens. "
623-
"Regex to match: /{regex}/",
622+
f"^{RoleGroup.ROLE_GROUP_NAME_PREFIX}{access_config.name_pattern}$",
623+
error=f"Role {access_config.name_validation_error} Regex to match: /{{regex}}/",
624624
),
625625
),
626626
)
@@ -838,9 +838,8 @@ class AppGroupSchema(SQLAlchemyAutoSchema):
838838
validate=validate.And(
839839
validate.Length(min=1, max=255),
840840
validate.Regexp(
841-
f"^{AppGroup.APP_GROUP_NAME_PREFIX}[A-Z][A-Za-z0-9-]*{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}[A-Z][A-Za-z0-9-]*$",
842-
error="Group name must start capitalized and contain only alphanumeric characters or hyphens. "
843-
"Regex to match: /{regex}/",
841+
f"^{AppGroup.APP_GROUP_NAME_PREFIX}{access_config.name_pattern}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}{access_config.name_pattern}$",
842+
error=f"Group {access_config.name_validation_error} Regex to match: /{{regex}}/",
844843
),
845844
),
846845
)
@@ -1130,9 +1129,8 @@ class InitialAppGroupSchema(Schema):
11301129
validate=validate.And(
11311130
validate.Length(min=1, max=255),
11321131
validate.Regexp(
1133-
f"^{AppGroup.APP_GROUP_NAME_PREFIX}[A-Z][A-Za-z0-9-]*{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}[A-Z][A-Za-z0-9-]*$",
1134-
error="Group name must start capitalized and contain only alphanumeric characters or hyphens. "
1135-
"Regex to match: /{regex}/",
1132+
f"^{AppGroup.APP_GROUP_NAME_PREFIX}{access_config.name_pattern}{AppGroup.APP_NAME_GROUP_NAME_SEPARATOR}{access_config.name_pattern}$",
1133+
error=f"Group {access_config.name_validation_error} Regex to match: /{{regex}}/",
11361134
),
11371135
),
11381136
)
@@ -1145,9 +1143,8 @@ class AppSchema(SQLAlchemyAutoSchema):
11451143
validate=validate.And(
11461144
validate.Length(min=1, max=255),
11471145
validate.Regexp(
1148-
"^[A-Z][A-Za-z0-9-]*$",
1149-
error="App name must start capitalized and contain only alphanumeric characters or hyphens. "
1150-
"Regex to match: /{regex}/",
1146+
f"^{access_config.name_pattern}$",
1147+
error=f"App {access_config.name_validation_error} Regex to match: /{{regex}}/",
11511148
),
11521149
),
11531150
)
@@ -1460,9 +1457,8 @@ class TagSchema(SQLAlchemyAutoSchema):
14601457
validate=validate.And(
14611458
validate.Length(min=1, max=255),
14621459
validate.Regexp(
1463-
"^[A-Z][A-Za-z0-9-]*$",
1464-
error="Tag name must start capitalized and contain only alphanumeric characters or hyphens. "
1465-
"Regex to match: /{regex}/",
1460+
f"^{access_config.name_pattern}$",
1461+
error=f"Tag {access_config.name_validation_error} Regex to match: /{{regex}}/",
14661462
),
14671463
),
14681464
)

config/config.default.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"FRONTEND": {
3+
"ACCESS_TIME_LABELS": {
4+
"43200": "12 Hours",
5+
"432000": "5 Days",
6+
"1209600": "Two Weeks",
7+
"2592000": "30 Days",
8+
"7776000": "90 Days",
9+
"indefinite": "Indefinite",
10+
"custom": "Custom"
11+
},
12+
"DEFAULT_ACCESS_TIME": "1209600",
13+
"NAME_VALIDATION_PATTERN": "^[A-Z][A-Za-z0-9\\-]*$",
14+
"NAME_VALIDATION_ERROR": "Name must start capitalized and contain only alphanumeric characters or hyphens."
15+
},
16+
"BACKEND": {
17+
"NAME_VALIDATION_PATTERN": "[A-Z][A-Za-z0-9-]*",
18+
"NAME_VALIDATION_ERROR": "name must start capitalized and contain only alphanumeric characters or hyphens."
19+
}
20+
}

craco.config.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
const CracoAlias = require('react-app-alias');
2+
const path = require('path');
3+
const webpack = require('webpack');
4+
const {loadAccessConfig} = require('./src/config/loadAccessConfig');
5+
6+
const accessConfig = loadAccessConfig();
27

38
module.exports = {
49
plugins: [
@@ -15,5 +20,10 @@ module.exports = {
1520
alias: {
1621
'@mui/styled-engine': '@mui/styled-engine-sc',
1722
},
23+
plugins: [
24+
new webpack.DefinePlugin({
25+
ACCESS_CONFIG: accessConfig,
26+
}),
27+
],
1828
},
1929
};

0 commit comments

Comments
 (0)