Skip to content

Commit de7fef3

Browse files
Make app, group, and tag descriptions requirable (#350)
Co-authored-by: Peter C <[email protected]>
1 parent 8ff5bee commit de7fef3

File tree

15 files changed

+485
-70
lines changed

15 files changed

+485
-70
lines changed

api/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,6 @@ def default_user_search() -> list[str]:
8989

9090
# Specify a custom app name
9191
APP_NAME = os.getenv("APP_NAME", "Access")
92+
93+
# Require descriptions for apps, groups, and tags
94+
REQUIRE_DESCRIPTIONS = os.getenv("REQUIRE_DESCRIPTIONS", "false").lower() == "true"

api/views/resources/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def put(self, app: App) -> ResponseReturnValue:
9292
schema = AppSchema(
9393
exclude=DEFAULT_SCHEMA_DISPLAY_EXCLUSIONS,
9494
)
95-
app_changes = schema.load(request.json)
95+
app_changes = schema.load(request.json, partial=True)
9696

9797
if app_changes.name.lower() != app.name.lower():
9898
existing_app = (
@@ -131,7 +131,7 @@ def put(self, app: App) -> ResponseReturnValue:
131131
abort(400, "Only tags can be modified for the Access application")
132132

133133
old_app_name = app.name
134-
app = schema.load(request.json, instance=app)
134+
app = schema.load(request.json, instance=app, partial=True)
135135

136136
# Update all app group names when updating app name
137137
if app.name != old_app_name:

api/views/resources/group.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def get(self, group_id: str) -> ResponseReturnValue:
108108
@FlaskApiSpecDecorators.response_schema(PolymorphicGroupSchema)
109109
def put(self, group: OktaGroup) -> ResponseReturnValue:
110110
schema = PolymorphicGroupSchema(exclude=DEFAULT_SCHEMA_DISPLAY_EXCLUSIONS)
111-
group_changes = schema.load(request.json)
111+
group_changes = schema.load(request.json, partial=True)
112112
old_group_name = group.name
113113

114114
if not group.is_managed:
@@ -172,7 +172,7 @@ def put(self, group: OktaGroup) -> ResponseReturnValue:
172172
).execute()
173173

174174
# Update additional fields like name, description, etc.
175-
group = schema.load(request.json, instance=group)
175+
group = schema.load(request.json, instance=group, partial=True)
176176
okta.update_group(group.id, group.name, group.description)
177177
db.session.commit()
178178

api/views/resources/tag.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def put(self, tag_id: str) -> ResponseReturnValue:
6969
)
7070

7171
schema = TagSchema(exclude=DEFAULT_SCHEMA_DISPLAY_EXCLUSIONS)
72-
tag_changes = schema.load(request.json)
72+
tag_changes = schema.load(request.json, partial=True)
7373

7474
if tag_changes.name.lower() != tag.name.lower():
7575
existing_tag = (
@@ -80,7 +80,7 @@ def put(self, tag_id: str) -> ResponseReturnValue:
8080
if existing_tag is not None:
8181
abort(400, "Tag already exists with the same name")
8282

83-
tag = schema.load(request.json, instance=tag)
83+
tag = schema.load(request.json, instance=tag, partial=True)
8484
db.session.commit()
8585

8686
# Handle group time limit constraints when modifying tags

api/views/schemas/core_schemas.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
from typing import AbstractSet, Any, Dict, List, Optional, Sequence, cast
1+
from typing import AbstractSet, Any, Dict, List, Mapping, Optional, Sequence, cast
22

33
from flask import current_app
44
from marshmallow import Schema, ValidationError, fields, utils, validate, validates_schema
55
from marshmallow.schema import SchemaMeta, SchemaOpts
66
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field
77
from sqlalchemy.orm import Session
8+
89
from api.access_config import get_access_config
910
from api.extensions import db
1011
from api.models import (
@@ -25,6 +26,52 @@
2526
access_config = get_access_config()
2627

2728

29+
def context_aware_description_field() -> fields.Field:
30+
"""
31+
Returns a context-aware description field that reads REQUIRE_DESCRIPTIONS
32+
from Flask app config at validation time instead of module import time.
33+
34+
This allows tests to parametrize the REQUIRE_DESCRIPTIONS setting without
35+
needing separate test environments or module reloading.
36+
"""
37+
38+
class ContextAwareDescriptionField(fields.String):
39+
def deserialize(
40+
self, value: Any, attr: Optional[str] = None, data: Optional[Mapping[str, Any]] = None, **kwargs: Any
41+
) -> Any:
42+
# Read config at deserialization time (when processing request data)
43+
require_descriptions = current_app.config.get("REQUIRE_DESCRIPTIONS", False)
44+
45+
# Check if field was provided in the input data
46+
field_was_provided = data is not None and attr is not None and attr in data
47+
48+
# If field wasn't provided and descriptions are required, raise error
49+
if not field_was_provided and require_descriptions:
50+
raise ValidationError("Description is required.")
51+
52+
# If field wasn't provided and descriptions are not required, return empty string
53+
if not field_was_provided:
54+
return ""
55+
56+
# Field was provided, validate it
57+
if value == "" and require_descriptions:
58+
raise ValidationError("Description must be between 1 and 1024 characters")
59+
60+
# Use parent deserialization for type conversion
61+
if value is None or value == "":
62+
return "" if not require_descriptions else self.fail("required")
63+
64+
result = super().deserialize(value, attr, data, **kwargs)
65+
66+
# Validate length
67+
if result and len(result) > 1024:
68+
raise ValidationError("Description must be 1024 characters or less")
69+
70+
return result
71+
72+
return ContextAwareDescriptionField(allow_none=True, load_default="", dump_default="")
73+
74+
2875
# See https://stackoverflow.com/a/58646612
2976
class OktaUserGroupMemberSchema(SQLAlchemyAutoSchema):
3077
group = fields.Nested(lambda: PolymorphicGroupSchema)
@@ -255,7 +302,7 @@ class OktaGroupSchema(SQLAlchemyAutoSchema):
255302
),
256303
),
257304
)
258-
description = auto_field(load_default="", validate=validate.Length(max=1024))
305+
description = context_aware_description_field()
259306

260307
externally_managed_data = fields.Dict()
261308

@@ -628,7 +675,7 @@ class RoleGroupSchema(SQLAlchemyAutoSchema):
628675
),
629676
),
630677
)
631-
description = auto_field(load_default="", validate=validate.Length(max=1024))
678+
description = context_aware_description_field()
632679

633680
externally_managed_data = fields.Dict()
634681

@@ -847,7 +894,7 @@ class AppGroupSchema(SQLAlchemyAutoSchema):
847894
),
848895
),
849896
)
850-
description = auto_field(load_default="", validate=validate.Length(max=1024))
897+
description = context_aware_description_field()
851898

852899
externally_managed_data = fields.Dict()
853900

@@ -1138,7 +1185,7 @@ class InitialAppGroupSchema(Schema):
11381185
),
11391186
),
11401187
)
1141-
description = fields.String(load_default="", validate=validate.Length(max=1024))
1188+
description = context_aware_description_field()
11421189

11431190

11441191
class AppSchema(SQLAlchemyAutoSchema):
@@ -1152,7 +1199,7 @@ class AppSchema(SQLAlchemyAutoSchema):
11521199
),
11531200
),
11541201
)
1155-
description = auto_field(validate=validate.Length(max=1024))
1202+
description = context_aware_description_field()
11561203

11571204
initial_owner_id = fields.String(validate=validate.Length(min=1, max=255), load_only=True)
11581205
initial_owner_role_ids = fields.List(fields.String(validate=validate.Length(equal=20)), load_only=True)
@@ -1282,7 +1329,7 @@ def load(
12821329
exclude=self._polymorphic_fields_intersection(group_class, self.exclude),
12831330
load_only=self._polymorphic_fields_intersection(group_class, self.load_only),
12841331
dump_only=self._polymorphic_fields_intersection(group_class, self.dump_only),
1285-
).load(data, session=session, instance=instance, transient=transient)
1332+
).load(data, session=session, instance=instance, transient=transient, **kwargs)
12861333
else:
12871334
raise ValidationError(f"Unexpected group type, expecting one of {self.TYPE_TO_GROUP_SCHEMA_MAP.keys()}")
12881335
raise ValidationError(f"Unable to validate with: {self.TYPE_TO_GROUP_SCHEMA_MAP}")
@@ -1466,7 +1513,7 @@ class TagSchema(SQLAlchemyAutoSchema):
14661513
),
14671514
),
14681515
)
1469-
description = auto_field(load_default="", validate=validate.Length(max=1024))
1516+
description = context_aware_description_field()
14701517

14711518
def validate_constraints(value) -> bool:
14721519
if not isinstance(value, dict):

src/config/accessConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ const accessConfig: AccessConfig = ACCESS_CONFIG as AccessConfig;
1212
export default accessConfig;
1313

1414
export const appName = APP_NAME;
15+
export const requireDescriptions = REQUIRE_DESCRIPTIONS;

src/globals.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
declare const ACCESS_CONFIG: any;
22
declare const APP_NAME: string;
3+
declare const REQUIRE_DESCRIPTIONS: boolean;
34

45
interface ImportMetaEnv {
56
readonly VITE_API_SERVER_URL: string;

src/pages/apps/CreateUpdate.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
} from '../../api/apiComponents';
2929
import {App, AppTagMap, OktaUser, Tag} from '../../api/apiSchemas';
3030
import {isAccessAdmin, isAppOwnerGroupOwner, ACCESS_APP_RESERVED_NAME} from '../../authorization';
31-
import accessConfig from '../../config/accessConfig';
31+
import accessConfig, {requireDescriptions} from '../../config/accessConfig';
3232

3333
interface AppButtonProps {
3434
setOpen(open: boolean): any;
@@ -181,10 +181,11 @@ function AppDialog(props: AppDialogProps) {
181181
return error?.message ?? '';
182182
}
183183
if (error.type == 'maxLength') {
184-
return 'Name can be at most 1024 characters in length';
184+
return 'Description can be at most 1024 characters in length';
185185
}
186186
return '';
187187
}}
188+
required={requireDescriptions}
188189
/>
189190
</FormControl>
190191
<FormControl margin="normal" fullWidth>

src/pages/groups/CreateUpdate.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
} from '../../api/apiComponents';
3232
import {PolymorphicGroup, AppGroup, App, OktaUser, Tag, OktaGroupTagMap} from '../../api/apiSchemas';
3333
import {canManageGroup, isAccessAdmin, isAppOwnerGroupOwner} from '../../authorization';
34-
import accessConfig from '../../config/accessConfig';
34+
import accessConfig, {requireDescriptions} from '../../config/accessConfig';
3535

3636
interface GroupButtonProps {
3737
defaultGroupType: 'okta_group' | 'app_group' | 'role_group';
@@ -296,10 +296,11 @@ function GroupDialog(props: GroupDialogProps) {
296296
return error?.message ?? '';
297297
}
298298
if (error.type == 'maxLength') {
299-
return 'Name can be at most 1024 characters in length';
299+
return 'Description can be at most 1024 characters in length';
300300
}
301301
return '';
302302
}}
303+
required={requireDescriptions}
303304
/>
304305
</FormControl>
305306
<FormControl margin="normal" fullWidth>

src/pages/tags/CreateUpdate.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
import NumberInput from '../../components/NumberInput';
3030
import {OktaUser, Tag} from '../../api/apiSchemas';
3131
import {isAccessAdmin} from '../../authorization';
32-
import accessConfig from '../../config/accessConfig';
32+
import accessConfig, {requireDescriptions} from '../../config/accessConfig';
3333

3434
interface TagButtonProps {
3535
setOpen(open: boolean): any;
@@ -254,10 +254,11 @@ function TagDialog(props: TagDialogProps) {
254254
return error?.message ?? '';
255255
}
256256
if (error.type == 'maxLength') {
257-
return 'Name can be at most 1024 characters in length';
257+
return 'Description can be at most 1024 characters in length';
258258
}
259259
return '';
260260
}}
261+
required={requireDescriptions}
261262
/>
262263
</FormControl>
263264
<Box sx={{fontWeight: 'medium', fontSize: 18, margin: '8px 0 4px 0'}}>Optional constraints</Box>

0 commit comments

Comments
 (0)