diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md new file mode 100644 index 0000000..c2bc6a0 --- /dev/null +++ b/PROJECT_CONTEXT.md @@ -0,0 +1,167 @@ +CA/PKI Backend Project Context +Stack + +Python 3 + psycopg (dict_row cursors) + +PostgreSQL database: ca + +Unit tests: unittest (python3 -m unittest discover) + +Database Schema (current assumptions) +entity + +id INT identity PK + +creation_ts TIMESTAMPTZ default now() + +creator INT FK → entity(id) (the entity that created this one; nullable) + +name VARCHAR(100) NOT NULL + +type VARCHAR(...) NOT NULL (e.g. person, group, device, alias) + +public_key VARCHAR(300) NOT NULL + +symmetrical_key VARCHAR(100) NULL + +status VARCHAR(...) NOT NULL default 'active' (values: 'active', 'revoked') + +expiration DATE NULL + +Index on entity(name) (and other indexes as needed) + +group_member + +group_id INT FK → entity(id) ON DELETE CASCADE + +member_id INT FK → entity(id) ON DELETE CASCADE + +role VARCHAR(10) + +PK (group_id, member_id) + +Index (member_id, group_id) + +Groups can contain any entity type, including other groups and devices. + +property + +Columns: (id INT FK → entity(id), property_name VARCHAR(100)) + +PK (id, property_name) + +Used for flags/roles such as "creator" + +metadata + +Intended “singleton row” table, enforced at application level + +Columns: name, comment, private_key, public_key + +log + +id SERIAL PK + +ts TIMESTAMPTZ default now() + +entry TEXT NOT NULL + +Every API mutation must log one row here. + +Core Business Rules + +Creators are not an entity type + +creator is a property (property_name='creator') on a person entity. + +insert_creator() creates a person entity and inserts the creator property. + +Revoked entities are immutable + +Any mutation on an entity requires ensure_entity_active(cursor, entity_id). + +Revoked entities cannot: + +Join groups or accept members + +Add/delete properties + +Change keys (public_key, symmetrical_key) + +Change status again + +Logging + +All changes to entity, group_member, property, metadata must call log_change(cursor, "...") + +Logging happens inside the same transaction (no extra commits). + +Python Modules (current structure) + +ca_core/entity.py + +Must provide: + +ensure_entity_active(cursor, entity_id) + +insert_creator(cursor, name, public_key) + +enroll_person(cursor, name, public_key, creator_id) + +create_group(cursor, name, public_key, creator_id) + +create_alias(cursor, target_entity_id) + +get_entity(cursor, entity_id) + +set_entity_status(cursor, entity_id, status, changed_by) (requires active entity) + +set_entity_keys(cursor, entity_id, public_key, changed_by) (active-only) + +set_symmetrical_key(cursor, entity_id, key, changed_by) (active-only) + +get_symmetrical_key(cursor, entity_id) + +ca_core/group_member.py + +Uses member_id (not person_id) + +Must prevent adding revoked groups/members (via ensure_entity_active) + +Logs add/remove membership + +ca_core/property.py + +Table is property(id, property_name) (NOT entity_id/name) + +Must reject mutations if entity revoked (immutability) + +Logs set/delete property + +ca_core/metadata.py + +Updates metadata fields and logs changes + +ca_core/db_logging.py + +log_change(cursor, message: str) inserts into log(entry) + +Tests + +tests/test_entity.py, tests/test_group.py, tests/test_property.py, tests/test_metadata.py + +Tests verify: + +Core behaviors (create, enroll, group membership, revoke immutability) + +Log entry is created for mutations (case-insensitive substring checks) + +Run via: + +python3 -m unittest discover + +Known gotchas + +Avoid naming a module logging.py (conflicts with stdlib). Use db_logging.py. + +Schema and code must stay aligned (e.g., property.id/property_name, group_member.member_id). diff --git a/ca_core/__pycache__/entity.cpython-313.pyc b/ca_core/__pycache__/entity.cpython-313.pyc index 9fe9993..78268d1 100644 Binary files a/ca_core/__pycache__/entity.cpython-313.pyc and b/ca_core/__pycache__/entity.cpython-313.pyc differ diff --git a/ca_core/__pycache__/metadata.cpython-313.pyc b/ca_core/__pycache__/metadata.cpython-313.pyc index c13f1fa..69bac03 100644 Binary files a/ca_core/__pycache__/metadata.cpython-313.pyc and b/ca_core/__pycache__/metadata.cpython-313.pyc differ diff --git a/ca_core/__pycache__/property.cpython-313.pyc b/ca_core/__pycache__/property.cpython-313.pyc index 0821302..c414d69 100644 Binary files a/ca_core/__pycache__/property.cpython-313.pyc and b/ca_core/__pycache__/property.cpython-313.pyc differ diff --git a/ca_core/entity.py b/ca_core/entity.py index 89fbdf8..8c565be 100644 --- a/ca_core/entity.py +++ b/ca_core/entity.py @@ -14,14 +14,25 @@ def ensure_entity_active(cursor, entity_id): raise ValueError("Entity is not active") +def _validate_ca_reference_for_group(ca_reference): + if ca_reference is None: + raise ValueError("ca_reference is required for groups") + if not isinstance(ca_reference, str): + raise ValueError("ca_reference must be a string") + if len(ca_reference) == 0: + raise ValueError("ca_reference cannot be empty") + if len(ca_reference) > 100: + raise ValueError("ca_reference must be at most 100 characters") + + def insert_creator(cursor, name, public_key): """ Creators are persons with property 'creator' in the property table. """ cursor.execute( """ - INSERT INTO entity (name, type, public_key, status) - VALUES (%s, 'person', %s, 'active') + INSERT INTO entity (name, type, public_key, status, ca_reference) + VALUES (%s, 'person', %s, 'active', NULL) RETURNING id """, (name, public_key), @@ -47,8 +58,8 @@ def enroll_person(cursor, name, public_key, creator_id): cursor.execute( """ - INSERT INTO entity (name, type, public_key, creator, status) - VALUES (%s, 'person', %s, %s, 'active') + INSERT INTO entity (name, type, public_key, creator, status, ca_reference) + VALUES (%s, 'person', %s, %s, 'active', NULL) RETURNING id """, (name, public_key, creator_id), @@ -59,38 +70,25 @@ def enroll_person(cursor, name, public_key, creator_id): return person_id -def create_group(cursor, name, public_key, creator_id): +def create_group(cursor, name, public_key, creator_id, ca_reference): ensure_entity_active(cursor, creator_id) + _validate_ca_reference_for_group(ca_reference) cursor.execute( """ - INSERT INTO entity (name, type, public_key, creator, status) - VALUES (%s, 'group', %s, %s, 'active') + INSERT INTO entity (name, type, public_key, creator, status, ca_reference) + VALUES (%s, 'group', %s, %s, 'active', %s) RETURNING id """, - (name, public_key, creator_id), + (name, public_key, creator_id, ca_reference), ) group_id = cursor.fetchone()["id"] - log_change(cursor, f"Created group {group_id} under creator {creator_id}") - return group_id - - -def create_alias(cursor, target_entity_id): - ensure_entity_active(cursor, target_entity_id) - - cursor.execute( - """ - INSERT INTO entity (name, type, creator, status) - VALUES (%s, 'alias', %s, 'active') - RETURNING id - """, - (f"alias_for_{target_entity_id}", target_entity_id), + log_change( + cursor, + f"Created group {group_id} under creator {creator_id} with ca_reference {ca_reference}", ) - alias_id = cursor.fetchone()["id"] - - log_change(cursor, f"Created alias {alias_id} for entity {target_entity_id}") - return alias_id + return group_id def get_entity(cursor, entity_id): diff --git a/ca_core/metadata.py b/ca_core/metadata.py index ce1cb20..cf301a5 100644 --- a/ca_core/metadata.py +++ b/ca_core/metadata.py @@ -54,3 +54,24 @@ def get_private_key(cursor): row = cursor.fetchone() return row["private_key"] if row else None + +def set_defense_p(cursor, defense_p: bool): + """Set the metadata defense_p flag. + + This table is treated as a singleton row at the application level. + Current convention in this codebase is to wipe and re-insert. + """ + cursor.execute("DELETE FROM metadata") + cursor.execute( + "INSERT INTO metadata (defense_p) VALUES (%s)", + (defense_p,) + ) + log_change(cursor, f"Updated metadata defense_p to {defense_p}") + + +def get_defense_p(cursor) -> bool: + cursor.execute("SELECT defense_p FROM metadata LIMIT 1") + row = cursor.fetchone() + if not row or row["defense_p"] is None: + return False + return bool(row["defense_p"]) diff --git a/ca_core/property.py b/ca_core/property.py index 9b91627..bf52d73 100644 --- a/ca_core/property.py +++ b/ca_core/property.py @@ -2,25 +2,71 @@ from db_logging import log_change from entity import ensure_entity_active -def set_property(cursor, entity_id, property_name): +def _validate_validation_policy(validation_policy: str) -> str: + if validation_policy is None: + return "default" + if not isinstance(validation_policy, str): + raise TypeError("validation_policy must be a string") + vp = validation_policy.strip() + if not vp: + raise ValueError("validation_policy cannot be empty") + if len(vp) > 19: + raise ValueError("validation_policy must be at most 19 characters") + return vp + + +def _validate_source(source): + if source is None: + return None + if not isinstance(source, str): + raise TypeError("source must be a string or None") + s = source.strip() + if len(s) > 150: + raise ValueError("source must be at most 150 characters") + # Allow empty string -> treat as NULL for cleanliness + return s if s else None + + +def set_property(cursor, entity_id, property_name, validation_policy="default", source=None): """ - Revoked entities are immutable: cannot add properties. - Schema: property(id, property_name) + Revoked entities are immutable: cannot add/update properties. + + Schema: property(id, property_name, validation_policy, source) + - validation_policy: CHAR(19) NOT NULL DEFAULT 'default' + - source: VARCHAR(150) NULL """ ensure_entity_active(cursor, entity_id) + if not isinstance(property_name, str) or not property_name.strip(): + raise ValueError("property_name must be a non-empty string") + if len(property_name) > 100: + raise ValueError("property_name must be at most 100 characters") + + vp = _validate_validation_policy(validation_policy) + src = _validate_source(source) + cursor.execute( """ - INSERT INTO property (id, property_name) - VALUES (%s, %s) - ON CONFLICT (id, property_name) DO NOTHING + INSERT INTO property (id, property_name, validation_policy, source) + VALUES (%s, %s, %s, %s) + ON CONFLICT (id, property_name) + DO UPDATE SET + validation_policy = EXCLUDED.validation_policy, + source = EXCLUDED.source """, - (entity_id, property_name), + (entity_id, property_name, vp, src), + ) + log_change( + cursor, + f"Set property '{property_name}' for entity {entity_id} " + f"(validation_policy={vp}, source={src})", ) - log_change(cursor, f"Set property '{property_name}' for entity {entity_id}") def get_properties(cursor, entity_id): + """ + Returns a list of property_name values for the entity. + """ cursor.execute( "SELECT property_name FROM property WHERE id = %s", (entity_id,), @@ -29,6 +75,22 @@ def get_properties(cursor, entity_id): return [r["property_name"] for r in rows] +def get_property(cursor, entity_id, property_name): + """ + Returns a dict_row with keys: property_name, validation_policy, source + or None if not found. + """ + cursor.execute( + """ + SELECT property_name, validation_policy, source + FROM property + WHERE id = %s AND property_name = %s + """, + (entity_id, property_name), + ) + return cursor.fetchone() + + def delete_property(cursor, entity_id, property_name): """ Revoked entities are immutable: cannot delete properties. diff --git a/create_tables.sql b/create_tables.sql index 68ace05..e578d3d 100644 --- a/create_tables.sql +++ b/create_tables.sql @@ -1,5 +1,5 @@ -- ------------------------ --- Metadata +-- Metadata (singleton row; enforced at application level) -- ------------------------ DROP TABLE IF EXISTS metadata; @@ -7,7 +7,8 @@ CREATE TABLE metadata( name VARCHAR(50), comment VARCHAR(200), private_key VARCHAR(500), - public_key VARCHAR(500) + public_key VARCHAR(500), + defense_p BOOLEAN NOT NULL DEFAULT false ); -- ------------------------ @@ -23,8 +24,14 @@ CREATE TABLE entity( type VARCHAR(20) NOT NULL, -- person, group, device symmetrical_key VARCHAR(100), public_key VARCHAR(300) NOT NULL, + ca_reference VARCHAR(100), status VARCHAR(20) NOT NULL DEFAULT 'active', - expiration DATE + expiration DATE, + CONSTRAINT entity_ca_reference_check CHECK ( + (type = 'group' AND ca_reference IS NOT NULL) + OR + (type <> 'group' AND ca_reference IS NULL) + ) ); CREATE INDEX idx_entity_name ON entity(name); @@ -50,7 +57,9 @@ DROP TABLE IF EXISTS property; CREATE TABLE property( id INT NOT NULL REFERENCES entity(id) ON DELETE CASCADE, - property_name VARCHAR(100), + property_name VARCHAR(100) NOT NULL, + validation_policy CHAR(19) NOT NULL DEFAULT 'default', + source VARCHAR(150), PRIMARY KEY (id, property_name) ); @@ -66,4 +75,3 @@ CREATE TABLE log( ); CREATE INDEX idx_log_ts ON log(ts); - diff --git a/create_tables.sql.old b/create_tables.sql.old deleted file mode 100644 index 3e59690..0000000 --- a/create_tables.sql.old +++ /dev/null @@ -1,48 +0,0 @@ -drop table metadata; - -create table metadata( - name varchar(50), - comment varchar(200), - private_key varchar(500), - public_key varchar(500) -); - -insert into metadata default vALUES; - -drop table entity cascade; - -create table entity( - id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - creation_ts TIMESTAMPTZ NOT NULL DEFAULT now(), - creator INT REFERENCES entity(id), - name varchar(100) NOT NULL, - group_p BOOLEAN NOT NULL, - geo_offset BIGINT, - --private_key VARCHAR(300) NOT NULL, - public_key VARCHAR(300) NOT NULL, - expiration DATE, - status VARCHAR(10) NOT NULL DEFAULT 'active' - - -); - -CREATE INDEX idx_entity_name ON entity(name); - -drop table group_member; - -create table group_member( - group_id INT NOT NULL REFERENCES entity(id) ON DELETE CASCADE, - person_id INT NOT NULL REFERENCES entity(id) ON DELETE CASCADE, - role VARCHAR(10), - PRIMARY KEY (group_id,person_id) -); - -CREATE INDEX idx_group_member_person_group ON group_member(person_id, group_id); - -drop table property; - -create table property( - id INT NOT NULL REFERENCES entity(id) ON DELETE CASCADE, - property_name VARCHAR(100), - PRIMARY KEY (id, property_name) -); diff --git a/tests/__pycache__/test_entity.cpython-313.pyc b/tests/__pycache__/test_entity.cpython-313.pyc index 461cf8b..f39ebef 100644 Binary files a/tests/__pycache__/test_entity.cpython-313.pyc and b/tests/__pycache__/test_entity.cpython-313.pyc differ diff --git a/tests/__pycache__/test_group.cpython-313.pyc b/tests/__pycache__/test_group.cpython-313.pyc index e79ac2f..d411805 100644 Binary files a/tests/__pycache__/test_group.cpython-313.pyc and b/tests/__pycache__/test_group.cpython-313.pyc differ diff --git a/tests/__pycache__/test_metadata.cpython-313.pyc b/tests/__pycache__/test_metadata.cpython-313.pyc index bcf6b50..f93a2b3 100644 Binary files a/tests/__pycache__/test_metadata.cpython-313.pyc and b/tests/__pycache__/test_metadata.cpython-313.pyc differ diff --git a/tests/__pycache__/test_property.cpython-313.pyc b/tests/__pycache__/test_property.cpython-313.pyc index 5662eae..73d4330 100644 Binary files a/tests/__pycache__/test_property.cpython-313.pyc and b/tests/__pycache__/test_property.cpython-313.pyc differ diff --git a/tests/test_entity.py b/tests/test_entity.py index 40b4fea..a0bb359 100644 --- a/tests/test_entity.py +++ b/tests/test_entity.py @@ -38,6 +38,7 @@ class TestEntityFunctions(unittest.TestCase): creator_id = entity.insert_creator(self.cur, "Creator1", "pubkey1") row = entity.get_entity(self.cur, creator_id) self.assertEqual(row["name"], "Creator1") + self.assertIsNone(row["ca_reference"]) log_entry = get_last_log(self.cur).lower() self.assertIn("creator entity", log_entry) @@ -47,17 +48,30 @@ class TestEntityFunctions(unittest.TestCase): creator_id = entity.insert_creator(self.cur, "Creator2", "pubkey2") person_id = entity.enroll_person(self.cur, "Person1", "pubkey_person", creator_id) + row = entity.get_entity(self.cur, person_id) + self.assertIsNone(row["ca_reference"]) + log_entry = get_last_log(self.cur).lower() self.assertIn("enrolled person", log_entry) self.assertIn(str(person_id), log_entry) - def test_create_group(self): + def test_create_group_requires_ca_reference(self): creator_id = entity.insert_creator(self.cur, "Creator3", "pubkey3") - group_id = entity.create_group(self.cur, "Group1", "pubkey_group", creator_id) + + with self.assertRaises(ValueError): + entity.create_group(self.cur, "GroupMissingRef", "pubkey_group", creator_id, None) + + def test_create_group_sets_ca_reference(self): + creator_id = entity.insert_creator(self.cur, "Creator4", "pubkey4") + group_id = entity.create_group(self.cur, "Group1", "pubkey_group", creator_id, "CA-REF-1") + + row = entity.get_entity(self.cur, group_id) + self.assertEqual(row["ca_reference"], "CA-REF-1") log_entry = get_last_log(self.cur).lower() self.assertIn("created group", log_entry) self.assertIn(str(group_id), log_entry) + self.assertIn("ca-ref-1".lower(), log_entry) def test_set_and_get_symmetrical_key(self): creator_id = entity.insert_creator(self.cur, "CreatorSym", "pubkey_sym") @@ -68,4 +82,3 @@ class TestEntityFunctions(unittest.TestCase): log_entry = get_last_log(self.cur).lower() self.assertIn("symmetrical_key", log_entry) - diff --git a/tests/test_group.py b/tests/test_group.py index 1984922..3452ad7 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -38,7 +38,7 @@ class TestGroupFunctions(unittest.TestCase): def test_add_and_get_members(self): creator_id = entity.insert_creator(self.cur, "Creator1", "pubkey1") person_id = entity.enroll_person(self.cur, "Person1", "pubkey_person", creator_id) - group_id = entity.create_group(self.cur, "Group1", "pubkey_group", creator_id) + group_id = entity.create_group(self.cur, "Group1", "pubkey_group", creator_id, "CA-GROUP-1") group_member.add_group_member(self.cur, group_id, person_id, "member") @@ -55,8 +55,8 @@ class TestGroupFunctions(unittest.TestCase): def test_nested_groups(self): creator_id = entity.insert_creator(self.cur, "Creator2", "pubkey2") - parent_group = entity.create_group(self.cur, "ParentGroup", "pubkey_parent", creator_id) - child_group = entity.create_group(self.cur, "ChildGroup", "pubkey_child", creator_id) + parent_group = entity.create_group(self.cur, "ParentGroup", "pubkey_parent", creator_id, "CA-PARENT") + child_group = entity.create_group(self.cur, "ChildGroup", "pubkey_child", creator_id, "CA-CHILD") group_member.add_group_member(self.cur, parent_group, child_group, "subgroup") @@ -69,7 +69,7 @@ class TestGroupFunctions(unittest.TestCase): def test_revoked_group_cannot_accept_members(self): creator_id = entity.insert_creator(self.cur, "Creator3", "pubkey3") - group_id = entity.create_group(self.cur, "RevokedGroup", "pubkey_group", creator_id) + group_id = entity.create_group(self.cur, "RevokedGroup", "pubkey_group", creator_id, "CA-REVOKED-GROUP") person_id = entity.enroll_person(self.cur, "Person2", "pubkey_person", creator_id) entity.set_entity_status(self.cur, group_id, "revoked", creator_id) @@ -79,11 +79,10 @@ class TestGroupFunctions(unittest.TestCase): def test_revoked_member_cannot_be_added(self): creator_id = entity.insert_creator(self.cur, "Creator4", "pubkey4") - group_id = entity.create_group(self.cur, "ActiveGroup", "pubkey_group", creator_id) + group_id = entity.create_group(self.cur, "ActiveGroup", "pubkey_group", creator_id, "CA-ACTIVE-GROUP") person_id = entity.enroll_person(self.cur, "RevokedPerson", "pubkey_person", creator_id) entity.set_entity_status(self.cur, person_id, "revoked", creator_id) with self.assertRaises(ValueError): group_member.add_group_member(self.cur, group_id, person_id, "member") - diff --git a/tests/test_metadata.py b/tests/test_metadata.py index d93cc8e..3ac95c2 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -58,3 +58,9 @@ class TestMetadataFunctions(unittest.TestCase): log_entry = get_last_log(self.cur).lower() self.assertIn("metadata keys", log_entry) + def test_set_and_get_defense_p(self): + metadata.set_defense_p(self.cur, True) + self.assertEqual(metadata.get_defense_p(self.cur), True) + + log_entry = get_last_log(self.cur).lower() + self.assertIn("defense_p", log_entry) diff --git a/tests/test_property.py b/tests/test_property.py index 87d3027..a472947 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -52,6 +52,12 @@ class TestPropertyFunctions(unittest.TestCase): props = property.get_properties(self.cur, person_id) self.assertIn("prop1", props) + details = property.get_property(self.cur, person_id, "prop1") + self.assertIsNotNone(details) + # CHAR(19) is space-padded in PostgreSQL; strip for comparison. + self.assertEqual(details["validation_policy"].strip(), "default") + self.assertIsNone(details["source"]) + log_entry = get_last_log(self.cur).lower() self.assertIn("set property", log_entry) self.assertIn("prop1", log_entry) @@ -76,6 +82,32 @@ class TestPropertyFunctions(unittest.TestCase): self.assertIn("deleted property", log_entry) self.assertIn("prop2", log_entry) + + def test_set_property_with_policy_and_source(self): + creator_id = entity.insert_creator(self.cur, "CreatorPolicy", "pubkey_policy") + person_id = entity.enroll_person( + self.cur, "PersonPolicy", "pubkey_person", creator_id + ) + + property.set_property( + self.cur, + person_id, + "prop_policy", + validation_policy="strict", + source="unit-test", + ) + + details = property.get_property(self.cur, person_id, "prop_policy") + self.assertIsNotNone(details) + self.assertEqual(details["validation_policy"].strip(), "strict") + self.assertEqual(details["source"], "unit-test") + + log_entry = get_last_log(self.cur).lower() + self.assertIn("set property", log_entry) + self.assertIn("prop_policy", log_entry) + self.assertIn("strict", log_entry) + + # ------------------------------------------------------------ # Immutability: revoked entity cannot mutate properties # ------------------------------------------------------------ @@ -93,4 +125,3 @@ class TestPropertyFunctions(unittest.TestCase): with self.assertRaises(ValueError): property.delete_property(self.cur, person_id, "prop3") -