felt rettelser

This commit is contained in:
Morten V. Christiansen 2026-02-26 17:28:43 +01:00
parent 7470cf7189
commit a1be210f58
17 changed files with 354 additions and 97 deletions

167
PROJECT_CONTEXT.md Normal file
View File

@ -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).

View File

@ -14,14 +14,25 @@ def ensure_entity_active(cursor, entity_id):
raise ValueError("Entity is not active") 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): def insert_creator(cursor, name, public_key):
""" """
Creators are persons with property 'creator' in the property table. Creators are persons with property 'creator' in the property table.
""" """
cursor.execute( cursor.execute(
""" """
INSERT INTO entity (name, type, public_key, status) INSERT INTO entity (name, type, public_key, status, ca_reference)
VALUES (%s, 'person', %s, 'active') VALUES (%s, 'person', %s, 'active', NULL)
RETURNING id RETURNING id
""", """,
(name, public_key), (name, public_key),
@ -47,8 +58,8 @@ def enroll_person(cursor, name, public_key, creator_id):
cursor.execute( cursor.execute(
""" """
INSERT INTO entity (name, type, public_key, creator, status) INSERT INTO entity (name, type, public_key, creator, status, ca_reference)
VALUES (%s, 'person', %s, %s, 'active') VALUES (%s, 'person', %s, %s, 'active', NULL)
RETURNING id RETURNING id
""", """,
(name, public_key, creator_id), (name, public_key, creator_id),
@ -59,38 +70,25 @@ def enroll_person(cursor, name, public_key, creator_id):
return person_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) ensure_entity_active(cursor, creator_id)
_validate_ca_reference_for_group(ca_reference)
cursor.execute( cursor.execute(
""" """
INSERT INTO entity (name, type, public_key, creator, status) INSERT INTO entity (name, type, public_key, creator, status, ca_reference)
VALUES (%s, 'group', %s, %s, 'active') VALUES (%s, 'group', %s, %s, 'active', %s)
RETURNING id RETURNING id
""", """,
(name, public_key, creator_id), (name, public_key, creator_id, ca_reference),
) )
group_id = cursor.fetchone()["id"] group_id = cursor.fetchone()["id"]
log_change(cursor, f"Created group {group_id} under creator {creator_id}") log_change(
return group_id cursor,
f"Created group {group_id} under creator {creator_id} with ca_reference {ca_reference}",
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),
) )
alias_id = cursor.fetchone()["id"] return group_id
log_change(cursor, f"Created alias {alias_id} for entity {target_entity_id}")
return alias_id
def get_entity(cursor, entity_id): def get_entity(cursor, entity_id):

View File

@ -54,3 +54,24 @@ def get_private_key(cursor):
row = cursor.fetchone() row = cursor.fetchone()
return row["private_key"] if row else None 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"])

View File

@ -2,25 +2,71 @@ from db_logging import log_change
from entity import ensure_entity_active 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. Revoked entities are immutable: cannot add/update properties.
Schema: property(id, property_name)
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) 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( cursor.execute(
""" """
INSERT INTO property (id, property_name) INSERT INTO property (id, property_name, validation_policy, source)
VALUES (%s, %s) VALUES (%s, %s, %s, %s)
ON CONFLICT (id, property_name) DO NOTHING 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): def get_properties(cursor, entity_id):
"""
Returns a list of property_name values for the entity.
"""
cursor.execute( cursor.execute(
"SELECT property_name FROM property WHERE id = %s", "SELECT property_name FROM property WHERE id = %s",
(entity_id,), (entity_id,),
@ -29,6 +75,22 @@ def get_properties(cursor, entity_id):
return [r["property_name"] for r in rows] 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): def delete_property(cursor, entity_id, property_name):
""" """
Revoked entities are immutable: cannot delete properties. Revoked entities are immutable: cannot delete properties.

View File

@ -1,5 +1,5 @@
-- ------------------------ -- ------------------------
-- Metadata -- Metadata (singleton row; enforced at application level)
-- ------------------------ -- ------------------------
DROP TABLE IF EXISTS metadata; DROP TABLE IF EXISTS metadata;
@ -7,7 +7,8 @@ CREATE TABLE metadata(
name VARCHAR(50), name VARCHAR(50),
comment VARCHAR(200), comment VARCHAR(200),
private_key VARCHAR(500), 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 type VARCHAR(20) NOT NULL, -- person, group, device
symmetrical_key VARCHAR(100), symmetrical_key VARCHAR(100),
public_key VARCHAR(300) NOT NULL, public_key VARCHAR(300) NOT NULL,
ca_reference VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'active', 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); CREATE INDEX idx_entity_name ON entity(name);
@ -50,7 +57,9 @@ DROP TABLE IF EXISTS property;
CREATE TABLE property( CREATE TABLE property(
id INT NOT NULL REFERENCES entity(id) ON DELETE CASCADE, 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) PRIMARY KEY (id, property_name)
); );
@ -66,4 +75,3 @@ CREATE TABLE log(
); );
CREATE INDEX idx_log_ts ON log(ts); CREATE INDEX idx_log_ts ON log(ts);

View File

@ -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)
);

View File

@ -38,6 +38,7 @@ class TestEntityFunctions(unittest.TestCase):
creator_id = entity.insert_creator(self.cur, "Creator1", "pubkey1") creator_id = entity.insert_creator(self.cur, "Creator1", "pubkey1")
row = entity.get_entity(self.cur, creator_id) row = entity.get_entity(self.cur, creator_id)
self.assertEqual(row["name"], "Creator1") self.assertEqual(row["name"], "Creator1")
self.assertIsNone(row["ca_reference"])
log_entry = get_last_log(self.cur).lower() log_entry = get_last_log(self.cur).lower()
self.assertIn("creator entity", log_entry) self.assertIn("creator entity", log_entry)
@ -47,17 +48,30 @@ class TestEntityFunctions(unittest.TestCase):
creator_id = entity.insert_creator(self.cur, "Creator2", "pubkey2") creator_id = entity.insert_creator(self.cur, "Creator2", "pubkey2")
person_id = entity.enroll_person(self.cur, "Person1", "pubkey_person", creator_id) 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() log_entry = get_last_log(self.cur).lower()
self.assertIn("enrolled person", log_entry) self.assertIn("enrolled person", log_entry)
self.assertIn(str(person_id), 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") 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() log_entry = get_last_log(self.cur).lower()
self.assertIn("created group", log_entry) self.assertIn("created group", log_entry)
self.assertIn(str(group_id), 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): def test_set_and_get_symmetrical_key(self):
creator_id = entity.insert_creator(self.cur, "CreatorSym", "pubkey_sym") 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() log_entry = get_last_log(self.cur).lower()
self.assertIn("symmetrical_key", log_entry) self.assertIn("symmetrical_key", log_entry)

View File

@ -38,7 +38,7 @@ class TestGroupFunctions(unittest.TestCase):
def test_add_and_get_members(self): def test_add_and_get_members(self):
creator_id = entity.insert_creator(self.cur, "Creator1", "pubkey1") creator_id = entity.insert_creator(self.cur, "Creator1", "pubkey1")
person_id = entity.enroll_person(self.cur, "Person1", "pubkey_person", creator_id) 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") 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): def test_nested_groups(self):
creator_id = entity.insert_creator(self.cur, "Creator2", "pubkey2") creator_id = entity.insert_creator(self.cur, "Creator2", "pubkey2")
parent_group = entity.create_group(self.cur, "ParentGroup", "pubkey_parent", 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) 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") 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): def test_revoked_group_cannot_accept_members(self):
creator_id = entity.insert_creator(self.cur, "Creator3", "pubkey3") 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) person_id = entity.enroll_person(self.cur, "Person2", "pubkey_person", creator_id)
entity.set_entity_status(self.cur, group_id, "revoked", 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): def test_revoked_member_cannot_be_added(self):
creator_id = entity.insert_creator(self.cur, "Creator4", "pubkey4") 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) person_id = entity.enroll_person(self.cur, "RevokedPerson", "pubkey_person", creator_id)
entity.set_entity_status(self.cur, person_id, "revoked", creator_id) entity.set_entity_status(self.cur, person_id, "revoked", creator_id)
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
group_member.add_group_member(self.cur, group_id, person_id, "member") group_member.add_group_member(self.cur, group_id, person_id, "member")

View File

@ -58,3 +58,9 @@ class TestMetadataFunctions(unittest.TestCase):
log_entry = get_last_log(self.cur).lower() log_entry = get_last_log(self.cur).lower()
self.assertIn("metadata keys", log_entry) 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)

View File

@ -52,6 +52,12 @@ class TestPropertyFunctions(unittest.TestCase):
props = property.get_properties(self.cur, person_id) props = property.get_properties(self.cur, person_id)
self.assertIn("prop1", props) 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() log_entry = get_last_log(self.cur).lower()
self.assertIn("set property", log_entry) self.assertIn("set property", log_entry)
self.assertIn("prop1", log_entry) self.assertIn("prop1", log_entry)
@ -76,6 +82,32 @@ class TestPropertyFunctions(unittest.TestCase):
self.assertIn("deleted property", log_entry) self.assertIn("deleted property", log_entry)
self.assertIn("prop2", 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 # Immutability: revoked entity cannot mutate properties
# ------------------------------------------------------------ # ------------------------------------------------------------
@ -93,4 +125,3 @@ class TestPropertyFunctions(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
property.delete_property(self.cur, person_id, "prop3") property.delete_property(self.cur, person_id, "prop3")