Skip to content

Commit d960a86

Browse files
committed
feat(security): enforce countries protocol on write actions
1 parent 4ed0c3e commit d960a86

4 files changed

Lines changed: 140 additions & 0 deletions

File tree

security/security.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from functools import wraps
22

33
from security.manager import get_protocol, is_permitted as manager_is_permitted, load_legacy_records
4+
from security.models import ALLOWED_ROLES
5+
from server.auth import ROLE_ADMIN
46

57
# import data.db_connect as dbc
68

@@ -61,6 +63,7 @@
6163

6264
# Features:
6365
PEOPLE = 'people'
66+
COUNTRIES = 'countries'
6467

6568
security_recs = None
6669
# These will come from the DB soon:
@@ -73,6 +76,26 @@
7376
},
7477
},
7578
},
79+
COUNTRIES: {
80+
CREATE: {
81+
CHECKS: {
82+
LOGIN: True,
83+
ALLOWED_ROLES: [ROLE_ADMIN],
84+
},
85+
},
86+
UPDATE: {
87+
CHECKS: {
88+
LOGIN: True,
89+
ALLOWED_ROLES: [ROLE_ADMIN],
90+
},
91+
},
92+
DELETE: {
93+
CHECKS: {
94+
LOGIN: True,
95+
ALLOWED_ROLES: [ROLE_ADMIN],
96+
},
97+
},
98+
},
7699
}
77100

78101

server/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from flask_restx import Api
1111

1212
from data import db_connect
13+
from security.security import read as load_security_records
1314

1415
APP_NAME = "Geographic Database API"
1516
APP_VERSION = "v1"
@@ -147,6 +148,8 @@ def create_app():
147148
if not any(isinstance(handler, InMemoryLogHandler) for handler in root_logger.handlers):
148149
root_logger.addHandler(InMemoryLogHandler())
149150

151+
load_security_records()
152+
150153
cors_origins = get_runtime_cors_origins()
151154
CORS(app, resources={r"/*": {"origins": cors_origins}})
152155
api = Api(

server/countries_endpoints.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import data.countries as countries_data
1111
import data.states as states_data
1212
from data.countries import VALID_CONTINENTS
13+
from security import require_protocol
1314
from server.states_endpoints import state_model
1415
from server.helpers import apply_pagination, validate_pagination
1516

@@ -307,6 +308,7 @@ def get(self):
307308
HTTPStatus.INTERNAL_SERVER_ERROR, f"Database error: {str(e)}"
308309
)
309310

311+
@require_protocol("countries", "create")
310312
@countries_ns.doc("create_country")
311313
@countries_ns.expect(country_create_model)
312314
@countries_ns.marshal_with(country_model, code=HTTPStatus.CREATED)
@@ -436,6 +438,7 @@ def get(self, country_code):
436438
f"Country with code '{country_code}' not found",
437439
)
438440

441+
@require_protocol("countries", "update")
439442
@countries_ns.doc("update_country")
440443
@countries_ns.expect(country_update_model)
441444
@countries_ns.marshal_with(country_model)
@@ -482,6 +485,7 @@ def put(self, country_code):
482485
f"Country with code '{country_code}' not found",
483486
)
484487

488+
@require_protocol("countries", "delete")
485489
@countries_ns.doc("delete_country")
486490
@countries_ns.response(HTTPStatus.NO_CONTENT, "Country deleted successfully")
487491
@countries_ns.response(HTTPStatus.NOT_FOUND, "Country not found", error_model)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Tests for the security protocol enforcement on countries write actions."""
2+
import json
3+
from http import HTTPStatus
4+
from unittest.mock import patch
5+
6+
import pytest
7+
8+
from server.app import create_app
9+
from server.auth import ROLE_ADMIN, ROLE_USER, create_access_token
10+
11+
12+
SAMPLE_COUNTRY = {
13+
"country_name": "Test Country",
14+
"country_code": "TC",
15+
"continent": "North America",
16+
"capital": "Test Capital",
17+
"population": 1000000,
18+
"area_km2": 50000.0,
19+
}
20+
21+
22+
@pytest.fixture
23+
def enforced_client(monkeypatch):
24+
monkeypatch.setenv("SECURITY_ENFORCEMENT", "true")
25+
monkeypatch.delenv("SECURITY_AUDIT_ONLY", raising=False)
26+
app = create_app()
27+
app.config["TESTING"] = True
28+
with app.test_client() as client:
29+
yield client
30+
31+
32+
def _bearer(role: str, user_id: str = "alice") -> dict:
33+
token = create_access_token(user_id, role, expires_hours=1)
34+
return {"Authorization": f"Bearer {token}"}
35+
36+
37+
def test_post_country_without_token_is_forbidden(enforced_client):
38+
response = enforced_client.post(
39+
"/countries",
40+
data=json.dumps(SAMPLE_COUNTRY),
41+
content_type="application/json",
42+
)
43+
assert response.status_code == HTTPStatus.FORBIDDEN
44+
45+
46+
def test_post_country_with_user_role_is_forbidden(enforced_client):
47+
response = enforced_client.post(
48+
"/countries",
49+
data=json.dumps(SAMPLE_COUNTRY),
50+
content_type="application/json",
51+
headers=_bearer(ROLE_USER),
52+
)
53+
assert response.status_code == HTTPStatus.FORBIDDEN
54+
55+
56+
def test_post_country_with_admin_role_is_allowed(enforced_client):
57+
with patch("data.countries.add_country") as mock_add:
58+
mock_add.return_value = True
59+
response = enforced_client.post(
60+
"/countries",
61+
data=json.dumps(SAMPLE_COUNTRY),
62+
content_type="application/json",
63+
headers=_bearer(ROLE_ADMIN),
64+
)
65+
assert response.status_code == HTTPStatus.CREATED
66+
67+
68+
def test_put_country_without_token_is_forbidden(enforced_client):
69+
response = enforced_client.put(
70+
"/countries/US",
71+
data=json.dumps({"population": 350000000}),
72+
content_type="application/json",
73+
)
74+
assert response.status_code == HTTPStatus.FORBIDDEN
75+
76+
77+
def test_put_country_with_admin_role_is_allowed(enforced_client):
78+
with patch("data.countries.update_country") as mock_update, \
79+
patch("data.countries.get_country_by_code") as mock_get:
80+
mock_update.return_value = True
81+
mock_get.return_value = SAMPLE_COUNTRY
82+
response = enforced_client.put(
83+
"/countries/TC",
84+
data=json.dumps({"population": 2000000}),
85+
content_type="application/json",
86+
headers=_bearer(ROLE_ADMIN),
87+
)
88+
assert response.status_code == HTTPStatus.OK
89+
90+
91+
def test_delete_country_without_token_is_forbidden(enforced_client):
92+
response = enforced_client.delete("/countries/US")
93+
assert response.status_code == HTTPStatus.FORBIDDEN
94+
95+
96+
def test_delete_country_with_admin_role_is_allowed(enforced_client):
97+
with patch("data.countries.delete_country") as mock_delete:
98+
mock_delete.return_value = True
99+
response = enforced_client.delete(
100+
"/countries/TC",
101+
headers=_bearer(ROLE_ADMIN),
102+
)
103+
assert response.status_code == HTTPStatus.NO_CONTENT
104+
105+
106+
def test_get_countries_remains_open_under_enforcement(enforced_client):
107+
with patch("data.countries.get_countries_filtered") as mock_get:
108+
mock_get.return_value = []
109+
response = enforced_client.get("/countries")
110+
assert response.status_code == HTTPStatus.OK

0 commit comments

Comments
 (0)