Skip to content

Commit 83f6071

Browse files
committed
docs(security): documentation on countries protocol enforcement and rollout flags
1 parent d960a86 commit 83f6071

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,45 @@ importing a larger slice of the
9898
[Countries States Cities Database](https://github.com/dr5hn/countries-states-cities-database/tree/master)),
9999
see `docs/LocalMongoTesting.md`. For a quick seeding guide, see `docs/Seeding.md`.
100100

101+
## Security
102+
103+
A reusable protocol framework lives in `security/`. Today it gates the
104+
country write endpoints (`POST`, `PUT`, `DELETE` on `/countries`) on a
105+
logged-in user with the `admin` role. Read endpoints stay open. Full design,
106+
the table of feature → action → required checks, and a recipe for adding new
107+
features are in [`security/security.md`](./security/security.md).
108+
109+
Enforcement is opt-in via env vars so the default `flask run` keeps current
110+
behavior:
111+
112+
```bash
113+
# off (default): permission check runs but is never enforced, requests pass through
114+
unset SECURITY_ENFORCEMENT
115+
116+
# block denied requests with 403
117+
export SECURITY_ENFORCEMENT=true
118+
119+
# observe-only: log denials at INFO but still let them through (good for rollout)
120+
export SECURITY_ENFORCEMENT=true
121+
export SECURITY_AUDIT_ONLY=true
122+
```
123+
124+
Tokens are HS256 JWTs minted by `server.auth.create_access_token(user_id,
125+
role)` with allowed roles `admin` and `user`. Override the signing secret
126+
with `JWT_SECRET` in any non-local environment.
127+
128+
```bash
129+
# mint an admin token for manual testing
130+
PYTHONPATH=. python -c "from server.auth import create_access_token; \
131+
print(create_access_token('alice', 'admin', 1))"
132+
133+
# use it
134+
curl -X POST http://localhost:8000/countries \
135+
-H "Authorization: Bearer <token>" \
136+
-H "Content-Type: application/json" \
137+
-d '{"country_name":"Test","country_code":"TC","continent":"North America","capital":"Test"}'
138+
```
139+
101140
## Common issues
102141

103142
- Module import errors (e.g., `No module named server`): run commands from the

security/security.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,70 @@
1818
- Security data should be in our DB.
1919
- First cut: let's use CRUD.
2020
- Having a dictionary of bools to specify checks needed will permit easily adding more later.
21+
22+
## Current Implementation
23+
24+
### Modules
25+
26+
- `security/models.py``SecProtocol` and `ActionChecks` value objects. `ActionChecks` supports `login`, `valid_users`, `allowed_roles`, `api_key` / `valid_api_keys`, `pass_phrase`, and `codes`. New checks can be added here without touching call sites.
27+
- `security/manager.py` — in-memory registry of protocols, plus `is_permitted(...)` which decodes the `Authorization: Bearer <jwt>` header via `server.auth.authenticate_request` and runs the checks for the given feature/action.
28+
- `security/decorators.py``@require_protocol(feature, action)` for Flask routes. Reads the `Authorization` header, calls the manager, stashes the decision on `flask.g.security_result`, and either passes through, audit-logs, or returns 403 depending on env flags.
29+
- `security/security.py` — legacy-format seed records (`temp_recs`) and the `read()` bootstrap that loads them into the manager. This is what the app calls at startup.
30+
31+
### Features with protocols today
32+
33+
| Feature | READ | CREATE | UPDATE | DELETE |
34+
|-------------|----------------|-------------------------------|-------------------------------|-------------------------------|
35+
| `people` | open | login + `ejc369@nyu.edu` only | open | open |
36+
| `countries` | open | login + role `admin` | login + role `admin` | login + role `admin` |
37+
38+
`countries` write actions are wired in `server/countries_endpoints.py` via `@require_protocol(...)` on `CountriesList.post`, `Country.put`, and `Country.delete`. Read endpoints are intentionally left open so unauthenticated browsing keeps working. Other features (states, cities) currently have no protocol record, which means they are open to all per the rule above.
39+
40+
### Rollout / enforcement flags
41+
42+
Both default to off, so adding a new protocol record does not change live behavior until an operator opts in.
43+
44+
| Env var | Default | Effect |
45+
|--------------------------|---------|-----------------------------------------------------------------------------------------------------|
46+
| `SECURITY_ENFORCEMENT` | `false` | When `true`, the decorator returns HTTP 403 for any denied request. |
47+
| `SECURITY_AUDIT_ONLY` | `false` | When `true` (with enforcement on), denied requests still pass through but are logged at INFO level. |
48+
49+
Audit-only mode is the recommended way to roll a new protocol out: turn enforcement on with audit-only, watch the logs (e.g. through `/dev/logs`) for unexpected denials, then drop audit-only.
50+
51+
### Auth tokens
52+
53+
`server/auth.py` issues HS256 JWTs with `sub` (user id) and `role` claims. Allowed roles are `admin` and `user`. The `JWT_SECRET` env var controls signing; the default `dev-jwt-secret` is for local use only.
54+
55+
### Adding a security protocol to a new feature
56+
57+
1. **Register the feature** by adding an entry to `temp_recs` in `security/security.py`, e.g.:
58+
59+
```python
60+
STATES = 'states'
61+
62+
temp_recs = {
63+
...,
64+
STATES: {
65+
CREATE: {CHECKS: {LOGIN: True, ALLOWED_ROLES: [ROLE_ADMIN]}},
66+
UPDATE: {CHECKS: {LOGIN: True, ALLOWED_ROLES: [ROLE_ADMIN]}},
67+
DELETE: {CHECKS: {LOGIN: True, ALLOWED_ROLES: [ROLE_ADMIN]}},
68+
},
69+
}
70+
```
71+
72+
2. **Decorate the route** in the matching endpoint module:
73+
74+
```python
75+
from security import require_protocol
76+
77+
@require_protocol("states", "create")
78+
@states_ns.expect(state_create_model)
79+
def post(self):
80+
...
81+
```
82+
83+
`@require_protocol` should be the outermost decorator so a 403 short-circuits before `@marshal_with` runs.
84+
85+
3. **Add a test** that flips `SECURITY_ENFORCEMENT=true` (see `server/tests/test_countries_security.py` as a template) covering: no token → 403, wrong role → 403, admin token → 2xx, and the relevant GET still 200.
86+
87+
No app-code changes are required beyond those three steps — `create_app()` already calls `security.security.read()` at startup, so the new entry is loaded automatically.

0 commit comments

Comments
 (0)