From 35752bce6bde55e102dce94f112a86c5b619db34 Mon Sep 17 00:00:00 2001 From: "Morten V. Christiansen" Date: Fri, 27 Feb 2026 07:27:19 +0100 Subject: [PATCH] refactors --- PROJECT_CONTEXT.md | 261 ++++++++++++++---- ca_core/__pycache__/entity.cpython-313.pyc | Bin 5184 -> 6551 bytes .../__pycache__/group_member.cpython-313.pyc | Bin 1068 -> 2663 bytes ca_core/__pycache__/metadata.cpython-313.pyc | Bin 3369 -> 4107 bytes ca_core/__pycache__/property.cpython-313.pyc | Bin 4062 -> 4085 bytes ca_core/entity.py | 53 +++- ca_core/group_member.py | 49 +++- ca_core/metadata.py | 52 ++-- ca_core/property.py | 2 +- tests/__pycache__/test_entity.cpython-313.pyc | Bin 6345 -> 9344 bytes tests/__pycache__/test_group.cpython-313.pyc | Bin 6898 -> 9636 bytes .../__pycache__/test_metadata.cpython-313.pyc | Bin 4893 -> 5579 bytes tests/test_entity.py | 29 ++ tests/test_group.py | 30 ++ tests/test_metadata.py | 8 + 15 files changed, 402 insertions(+), 82 deletions(-) diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index c2bc6a0..995e8d4 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -1,34 +1,61 @@ -CA/PKI Backend Project Context +CA/PKI Backend Project Context (Updated) Stack Python 3 + psycopg (dict_row cursors) PostgreSQL database: ca -Unit tests: unittest (python3 -m unittest discover) +Unit tests: unittest + +Run via: python3 -m unittest discover + +Current test count: 17 Database Schema (current assumptions) entity -id INT identity PK +id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY -creation_ts TIMESTAMPTZ default now() +creation_ts TIMESTAMPTZ DEFAULT now() -creator INT FK → entity(id) (the entity that created this one; nullable) +creator INT FK → entity(id) (nullable) name VARCHAR(100) NOT NULL -type VARCHAR(...) NOT NULL (e.g. person, group, device, alias) +type VARCHAR(...) NOT NULL + +Allowed types: person, group, device public_key VARCHAR(300) NOT NULL symmetrical_key VARCHAR(100) NULL -status VARCHAR(...) NOT NULL default 'active' (values: 'active', 'revoked') +status VARCHAR(...) NOT NULL DEFAULT 'active' + +Values: 'active', 'revoked' expiration DATE NULL -Index on entity(name) (and other indexes as needed) +ca_reference VARCHAR(100) NULL + +Constraint +CHECK ( + (type = 'group' AND ca_reference IS NOT NULL) + OR + (type <> 'group' AND ca_reference IS NULL) +) + +Rule: + +Groups MUST have a ca_reference + +All other entity types MUST have ca_reference IS NULL + +Indexes: + +Index on entity(name) + +Other indexes as needed group_member @@ -38,69 +65,169 @@ member_id INT FK → entity(id) ON DELETE CASCADE role VARCHAR(10) -PK (group_id, member_id) +PRIMARY KEY (group_id, member_id) Index (member_id, group_id) -Groups can contain any entity type, including other groups and devices. +Groups can contain: + +persons + +devices + +other groups property -Columns: (id INT FK → entity(id), property_name VARCHAR(100)) +id INT FK → entity(id) ON DELETE CASCADE -PK (id, property_name) +property_name VARCHAR(100) NOT NULL -Used for flags/roles such as "creator" +validation_policy CHAR(19) NOT NULL DEFAULT 'default' + +source VARCHAR(150) NULL + +PRIMARY KEY (id, property_name) + +Notes: + +validation_policy is CHAR(19) and padded by PostgreSQL. + +Used for flags/roles such as "creator". metadata -Intended “singleton row” table, enforced at application level +Singleton row table (enforced at application level). -Columns: name, comment, private_key, public_key +Columns: + +name + +comment + +private_key + +public_key + +defense_p BOOLEAN NOT NULL DEFAULT false + +defense_p: + +Global system flag. + +Logged on change. log -id SERIAL PK +id SERIAL PRIMARY KEY -ts TIMESTAMPTZ default now() +ts TIMESTAMPTZ DEFAULT now() entry TEXT NOT NULL -Every API mutation must log one row here. +Every API mutation must insert exactly one row here. Core Business Rules +Creators are NOT an entity type -Creators are not an entity type +"creator" is a property (property_name='creator') on a person. -creator is a property (property_name='creator') on a person entity. +insert_creator(): -insert_creator() creates a person entity and inserts the creator property. +Creates a person + +Adds "creator" property Revoked entities are immutable -Any mutation on an entity requires ensure_entity_active(cursor, entity_id). +All entity mutations must call: -Revoked entities cannot: +ensure_entity_active(cursor, entity_id) -Join groups or accept members +Revoked entities CANNOT: + +Join groups + +Accept members Add/delete properties -Change keys (public_key, symmetrical_key) +Change public_key + +Change symmetrical_key Change status again -Logging +Group CA Reference Rule -All changes to entity, group_member, property, metadata must call log_change(cursor, "...") +group entities must include a non-null ca_reference. -Logging happens inside the same transaction (no extra commits). +person and device must not define ca_reference. -Python Modules (current structure) +Enforced at: +Database level (CHECK constraint) + +Python validation level + +Property Metadata + +Each property includes: + +validation_policy + +source + +Defaults: + +validation_policy = 'default' + +source = NULL + +Property mutations: + +Require entity to be active + +Must log changes + +Metadata Defense Flag + +defense_p: + +Boolean system-wide flag + +Default: false + +Must be logged when changed + +Logging Rules + +All changes to: + +entity + +group_member + +property + +metadata + +Must call: + +log_change(cursor, "...") + +Logging: + +Happens inside same transaction + +No extra commits + +Exactly one log row per mutation + +Python Modules ca_core/entity.py -Must provide: +Provides: ensure_entity_active(cursor, entity_id) @@ -108,60 +235,94 @@ 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) +create_group(cursor, name, public_key, creator_id, ca_reference) get_entity(cursor, entity_id) -set_entity_status(cursor, entity_id, status, changed_by) (requires active entity) +set_entity_status(cursor, entity_id, status, changed_by) -set_entity_keys(cursor, entity_id, public_key, changed_by) (active-only) +set_entity_keys(cursor, entity_id, public_key, changed_by) -set_symmetrical_key(cursor, entity_id, key, changed_by) (active-only) +set_symmetrical_key(cursor, entity_id, key, changed_by) get_symmetrical_key(cursor, entity_id) ca_core/group_member.py -Uses member_id (not person_id) +Uses member_id -Must prevent adding revoked groups/members (via ensure_entity_active) +Prevents adding revoked groups/members -Logs add/remove membership +Logs membership add/remove ca_core/property.py -Table is property(id, property_name) (NOT entity_id/name) +Table: -Must reject mutations if entity revoked (immutability) +property(id, property_name, validation_policy, source) -Logs set/delete property +Rules: + +Reject mutations if entity revoked + +Logs set/delete + +Default policy 'default' + +validation_policy is CHAR(19) ca_core/metadata.py -Updates metadata fields and logs changes +Updates metadata fields + +Manages defense_p + +Logs changes ca_core/db_logging.py +log_change(cursor, message: str) -log_change(cursor, message: str) inserts into log(entry) +Inserts into log(entry). Tests -tests/test_entity.py, tests/test_group.py, tests/test_property.py, tests/test_metadata.py +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) +Creation and enrollment -Log entry is created for mutations (case-insensitive substring checks) +Group membership + +Revocation immutability + +CA reference enforcement + +Property metadata fields + +defense_p behavior + +Log entry creation (case-insensitive substring checks) Run via: python3 -m unittest discover +Known Gotchas -Known gotchas +Do NOT name module logging.py (conflicts with stdlib) -Avoid naming a module logging.py (conflicts with stdlib). Use db_logging.py. +Schema and code must stay aligned: -Schema and code must stay aligned (e.g., property.id/property_name, group_member.member_id). +property.id (NOT entity_id) + +group_member.member_id + +entity.ca_reference constraint + +CHAR(19) pads values with spaces diff --git a/ca_core/__pycache__/entity.cpython-313.pyc b/ca_core/__pycache__/entity.cpython-313.pyc index 78268d16113a92b23d119cbe06148eb604c51cad..782ccd8f60c70577584b90982db0c15ea070e9c5 100644 GIT binary patch delta 1806 zcmcIk%~M-d6n{6#BgxB4$OoSdlq(dIXG1DQQ2{}Qrh)_5A;r?sX(lF@BqO}weJ`b? zi@-`*DA2p8Zq%-77j~S9i*D5J+$j!>GMPH#=pP^xm~r98b6-Bd8C`m3?s@0kkKeiH z_dEAK4&EJb9XXwn0IlK0dzn!4j;jqV>;z`qQTdEO<){;^Gv!hmcW!-yrq~N8SiFIn z5R!|Usn3MG?4E_TnE3u`6X9!ye)J_%#Lk^>``eKqq|EcCsD)2O#i3O}h#O-pEVf!3 z=26u8oQd7=V)lhiDomquf?~_xk>-e=qbj~ia|CA=v7RQFsCq_U!x;_7ae|V#o~2<) z8n5{eoAymt%0b_bKqo)CZW>;}W#Va%mlK{J#CX@v0|1mYb zOz_J%P>mFj^1ac>bY$YH0m(i@{=(&wg~OK`D)9N?>VkfJdG7U@IuyP(8JUaVOcLOF zH9S6hsXkSU(yNuM)j$tS)Cx>d8#i1Ujq`~yrY`uFq>L7;t}M__7~x$@fLRv~-Gby9*btN;m{_?-mS6Cu{}q}XP0&z!Cgy`x*<-YrZVI5{ES9h zK+I!qE^x7p7Ddm4+Po2zGAZW#h7?h?93?f`V!#WzmCHL`-5z7C{p= zLH=8D5V9t2O>{jS*9$lu*Nmd6>sRMIEaiKu4D#5^IZZ!_1bEfCS;b5n;n)Vr)DE6v zq7L|!q?6qm=b|Q!ix`PUbR#P*L`_AQ|&j^^#fpB>7N4&{lf zZMS`N&o#Dd8#^=$@-xgSK5kl&CU}99ljDS?;`ibb5~&}s*o5_pNV~ZAskO_~3(hEX zZ5a%dfn5B-+J*}GcY$h}U0MPZm8{}QmExS5B=j`!seDecf%PoENpVAu8Ywtf1v!6m zG3vxB!0dgvVwE<nY$ z7={HOu!qjC+OA9UD@k4q)dx`1<9bem;0f379$sH}IMxUlPGIp`V+-nj7XD~I7{y`~ zEtSD9+20{+v77Ow9HAH4mOCI0!ssV##^+~0xerkX`_6M-dsmOPc z_4<0mVOaYR_D>dEB`NuKY$=;cWz-Zk!+7QPv~rKXQO0ecyi_R{of;(YBq>jU@{>>= znsUqsg7MT;o;=&gayFS;BBPWSIe$&GDKLj-gwQX-Q{@H+>K`w6*w7>@xA>4!4%yHv r;QCQZdBlgB%b_;ZR30>;&az}j3#i;?MQ@;TlNk*h$yPLvj97mIt`36f6DwGN>LIf>(Xz^qSO$echi4#+?Am-p* z!W`6FFQpX3%X(8pe}dpagPuHiG;Y0lab6Wf@P+4LKA)L)9#voGjpJ}wllbiq_B+Ms z#Bkv9BzGmy)(hdugI7?ab;!G2hy!Tu*jMp^NzVcZero6mT2l|;qWeSbf;yGAvDLWU zN9u;I>Qn8HSMN0GP3WX~f=8qo!yeOEnknwLkOTgWy+ayAJM<-d0g{w63J{@d#zH8? zRVeClk#>!Won>>GG2CJrOCJNyW9wa@?7lQ?fDO7CJ+zXX*)!I~60K&Yjs4LegBdEB zsksQJ&}QK{)lK;y@muDSeTLJQ7{eW=zvA@5{Hh-LP!`<^J&omqc{Xp*huCviql5To zv%>y1W4OXJmcA?QlY|A?B70b3Dg-bjY@KPU?zHaKcN^VN3(NF&J{LU8(H=c?Qsh`O z$dPK7f>n;)anIW^NdH&m_fg8!c1{P+aqT_suYpeQo%DTiV-%M{@go$=qSz3{AQZ|d zT8<=)3fo|sDl5HybJT6EqBt)whO107KLC7^PI+@0EO`qG)WEZnV0p_1%zN1wB)p;z a7ClXd>)<71*a9z-@I&=jmthlr4fqMWDTws| diff --git a/ca_core/__pycache__/group_member.cpython-313.pyc b/ca_core/__pycache__/group_member.cpython-313.pyc index ed77cd0b44e4b79df500a23209b3ca39accf9dda..3ecf61656d196fe0681d98efccc55a8bcde025b8 100644 GIT binary patch literal 2663 zcma)8O>7fK6rTODy;(aZ4Uhzy(4ittOaUh%NW~OT3P$`Sj+%`jC{nGBcj9cZcca;L zAnt)64kYRUq!x)o4{&R5J?2#P$kE7DjaC6wDi>}MN$-8Le|Dl&>XY{E?96*_-n{p{ zH@nr|E+e4i7ccZ5VhH`sJ_6$X!PW>2o+1S)+zoV{qa0>q{swM`%C&sboAii}qJDJ70hR@@E%Msanig#k<9w}CB;z)DEF3y_3Oq4(p8Jl^uirIT~rH2lah3an$?9ug;ZtmlW=duSGbBJ-}x-g|UZB z?Xb}nNGg2O#tc~ZC?8r@gv^f!sche-j@Z=6bCA18u6B@`Wr8bJ%f_<=tJt!sZj@@D z?3y-A<0Ppp*h{{^dxrknr#q!=Trt7U@DK*XQa~r6Rx0Yqx}_VItr|t*2w>l7n}WP# zDTQ=L*nSI+d{Zq~{VAbL3_yv)gZI3%z&`O_`s)P%$|9{>TPnXtr~ zvI>{E>nEuRj@HD6(8vox>I@Me6Q(q93_&-U?i^{^52Qj^G^GMy%`#v#_lyMAT|n+- zJv!Z)7e|Da^RT*dDUXy`o=+l0%5%Wm@?sL5jy5GrVoI+4GpcC-FRxIsZNk_s-BYqW zSuGPQBV&Bhn<_xVZ~;ll9H9hYgH;22%`WIH=A74U23#WpuCizQ|7Z_FTd+(ct&v5& z==YG6Yhx#%@ow1TyShD(E9yNAAT7agc*s@LstaYk2(YxUN(rXqTLS3UWGCYKR~z=) z)Sd_Pd*!YZ)kgiMI_z~+&ww93udjwSS$D;wxyycZSp?Wru6AVH?Tc6kx5|{L+7i|c zY|j%f(6w1`-lJw+otJ3Hey9h8S<&b7^|C9gfdV%Qs9l#<{5;u z(rzZz1I%K8c3twiMTe?s1y941rbj@ai%Gr!wv4trQRL9GuGix64Y7y4KYthNeim)S zQa|5n#75kPFxE(p*QN1yQr8o^A@weg{^eaD>(Y(&wvXyk-*!ZZL^mZQB_G{?aK91j zc@;b6YMyN*Gj%EB%Wv{RTLQ%7&Sz>}!rSp^B)Z*!BKuYrpY=UodVc5E!)sU9GQUr~ zJo<8fy?gu*G3WK4@KOPnS)^Dzw!Q)OU;StA>wJ$(KdgT+=2Org;Bw$|wWTn{J{ZHG zdk_eh-AlhU(&5S;Pz~26CNF0v@mIH-$O@NIf`zE zpG!%Otj!kSH>?Erq6
XmE@Ze7SSaJhT_f?Qn>gwmH4Q-cn7f%d~XyZ0^T*QUU6 h+#7WC4I20m6}Z6<35jd}(1p1PZtDsc=lWa|{{l`(T~h!6 delta 554 zcmYLFL2DC16n?WaJDJ^0wjsnc*oYCe?bdo~y-9;7#X}IwLP1Pzvui9Qq|7!T^kD0u zDjqZpLJl5stl+_8|A6;^9>QX!w;sF%6YsuBA`ZOoy?O6@?|n1p+0ViqrcyTGsXXrP z{50Bbo-DQ(JXSM3-hQ&2W4bmy7J7;{e5nq_oGP%K`Y7_Hjf_uM=`CZzD#9ak$_F3T z4c~BKo%#fk`P79K{-0R`&n&C2{HR2wUKA(7=V7(A7fQXxV==F`WYmw!(psVi2nXh#e% z&VrUQ{-1joe8AT~B&sW}w|jHyOmI?B=b|$28Iof;7Ei;y>a(qS9TN#mNP!UPdBvi1 z)LZM~S{g62I_n`EK>wd!hI(z(``Q_$5+)y{A(?qsdQY{x-_>6r v^_)*I&Pxa5Sln(jgH}CUlUckHwCDoDfD%GZ;mRp2{N^)+53gC|(zxv}&;oq) diff --git a/ca_core/__pycache__/metadata.cpython-313.pyc b/ca_core/__pycache__/metadata.cpython-313.pyc index 69bac03f682f0ad503cee97d291a8950ebc58f24..6f362b664d870b37906c23bf1cc9902bdaec13cb 100644 GIT binary patch literal 4107 zcmd5<%}*Og6rc63jj_Q55<;Mm4qpi-2~HDe6h4wrVp1AnqO2VyO;mR=gK=Ve-Ptt> zZdy_GP*5X3yyLaiIyfrEg}z=7X`3_Xa#E( zZD4I;1=tGF4%RMKf~^!C_Z__!(bU?xw)JpDu;ui87Uqn zA&#}_j{v_sygR>xd5!|;R&lTHByxRvD`p(a~85rhJqXt6?l(uL4H zR?r@!)tGgH6NR`XU=fA>I|xa2Fs}SMti}k8!aVrf`hNj`C(K=8%q>EQ$6wM53yMDma!@R6OAuzl)Oz_!!arDFH7j zuHuj!O~&F~iW^Jusl0DoR`H`qf?yeDrffxH)7l_&EhLsu=T4jJz*A&eN=8*}8?e+! zTp<+J+EY3(B*#?=`fyB!IH-UFEeNpp)A$j=REj9jEMsX_j=(-+laX*zPAV8SJ_V~t zb%@?^?}#6dOay%Xu5LFT7I6r+FnVj^h8fBn4^2MG(4A@@^NHSDemv&$PnZt)s&{la zFz&~XdmB`8{ee)7M*aVz5}ZkgFh%Nhqn3oPC<8p&2;J93P3RImc}& znj~IIW$Na0wosBPGR^TYh%($M473>IT$^V`juB51!sJOjjCev)Fa$C27^~<>%;oBX zL<3CFJnJBv2QZ9Sz9=Cu^JuFMS)D)me(+@uU(5>q8;(QIJd2*!j*sR?{%pa^;;Z_X zU#@iyJZ)bHEp;wFczQlOm1!7Uw-0SpRxNz#WP!Te-m+RAK!^PPMq!Zv= z=)9%S8%A#4mud;zygKan7S2!f`f*%}5j=!Dl~iLu12sU`$R({0V6?((!J;fHDO<4H zLLp00PGBAiTLi|mnzh&!q6S$jJq*=j@G57(%%e@A@x_U((7GWw@61+9FESz7MT^z^= zCzpG{OCKvSW2lmyAiaHDt7Ch+cHaXJ7x zeXAAeR95&*hfZHQoW7fBxV$c0`2e6}aI3NX^5|)V?a_K>jJ`yZxjK^dfxRn1l+uW(^E#i}+yC2wQFVFOq14|+~3D;`y zA(_+gavCRSWESpFvn5r(4?*CpII7l0dVW(%|)uXqEgEs7! zH!2Tj?5(?2-fG*mBWn#XQA5wV;Q0V>bW*3F!j?;~XOoB+qZ|Mw7lTq^0@?= ziDeSdU{)9^At2liXnL~dY|aR0UL67R0R*JW2uKld$|u*y2S=nvi%4Mx%6R5-nUD-+ zg-azNY3+cJe7ELo$p}5KI>2Zl$yI`419?G$iT8B1^KK!v0t!{rmFCrLt_n{_rLZQ2 znezP)2@JC6Uu3X<2BrX>1}&@$U?s1H3m;?3H$e%gu~`D@IvUL@UtJqB>Dg9}910U? zcggX6Oi*xu+r(FVozPBpc*Xo#&8`x206hu)UgqNgGmmzwkhQ5)iCkE9uQq3eQT;l( zuv(e^Hq$V&E?mtYv5gWnN#iAoM1aXOMe+m+?^u!83W|h%Gt3v!t^NnhkcXgF1Yhce zo*Cw*4`2!ws!=yz&@4G&QkJ9pub^s{CFX4O6pXtGuQC9pNVg1td@IQjr)#Nw>ET*k z+ge@sM$M5;XU!77bbaj*e(h|{2(4N;eeUXhmeeEABF>~nv literal 3369 zcmd5;%}*Og6rb^qjlF9FB1AL<+JTUSrNnL;lPH0tjR>eT#F4Uglq!|zVo!_{+iT6P zN#mwcdMY`UOQar=dP!~^`)3#hBx}|7(o=5;=%uH=S+74fm@2n2(!8CI_ukI__BZdn z{YXSc&}Ir7x%nVMe~L+?`P{+&eHgq#8q)A>^d;t)BF#65xKEQnN*V!4G(Skc762L0 zxWS7z_dNIq=jTeh8KXeKD z{$miYPzv3{FnFhFc*v!+@(l`4S&%e0h) z4z&#W#%Fi2Z&|u*F)q%b`s_fu%uR#aMCh2}n3US1u@$4hVr7%@Sm{YFme!Zj24}Ix zS)!$9cF<(jk}%>57!l~^6%a2_jmSSHen?bLsM}=1oz#LCYoW+m*MVR12M&VB-?O$_ zJ@x5keDhp&;OY*!whyK#-Hxz0pM(E5FnCY=o7jSh+os%S5vTCDJJ#Xfrm*G{zjzGj zHHK6vQ5n$y_1PqK!16#(7Z>gVs(nx;Y7=4WY~10;k8R^t1qXkE{3K|ffc~E(i`PQEYqFPQ zQ=8e%uc`x|?U2vwX#TGx8*@q40HQO``r$))w-CMJlk2y#PAWx^ssYLX><$TycX)Mm zn_TnoYM27Ap1r(LB@-JN5OpNluS+xBWx(HiUbl86!AdmE_nj7^dxE13$QVHK{gyIZCDGSoARI{SP=-BXMUyR+ zjzI<>s=xA!#w=>BuvTHwJRyCQ*RxSsPOjukYU#^)M#X?Lrx4Lis#7yp%;uS86e%Q1 zs@FZ~r4n3j-O9moo~^QcRGuw!4ylBujp8ai#ZImWuLXZ;Bg2+;v+moVPje+k^Konao2d2l|EBb;OY66{dg)tbq)J8{ zI1_5vmNUysQ1EAgi%tdX6t7&=XHJE|MMmW!$U6bexx)Ocn2O)(9On&koVkf%reI{s zd3J-VurKnVIS*o2!WjRKM)m>$JiXT^;qkq20N=!7`0-vigg@E)K*G_zu!Li~VFl0O WjpP9m&)p$E9{oA_H-d-ro__%)kD)^V diff --git a/ca_core/__pycache__/property.cpython-313.pyc b/ca_core/__pycache__/property.cpython-313.pyc index c414d69a3b81aacfe500bd77717285b8b0923976..5bf21dd8b759b9edafef4ff19361abdd43436ad5 100644 GIT binary patch delta 63 zcmca7|5cv%GcPX}0}u#VEX+KTSgvj715=?m-t diff --git a/ca_core/entity.py b/ca_core/entity.py index 8c565be..b1e0b07 100644 --- a/ca_core/entity.py +++ b/ca_core/entity.py @@ -4,6 +4,7 @@ from db_logging import log_change def ensure_entity_active(cursor, entity_id): """ Ensure an entity exists and is active. + Revoked entities are immutable. """ cursor.execute("SELECT status FROM entity WHERE id = %s", (entity_id,)) @@ -25,8 +26,43 @@ def _validate_ca_reference_for_group(ca_reference): raise ValueError("ca_reference must be at most 100 characters") +def is_creator(cursor, entity_id): + """ + Return True if the entity is a creator. + + A creator is: + - an entity of type 'person' + - with a row in property where property_name = 'creator' + """ + cursor.execute("SELECT type FROM entity WHERE id = %s", (entity_id,)) + row = cursor.fetchone() + if row is None: + return False + if row["type"] != "person": + return False + + cursor.execute( + "SELECT 1 FROM property WHERE id = %s AND property_name = %s", + (entity_id, "creator"), + ) + return cursor.fetchone() is not None + + +def ensure_creator(cursor, creator_id): + """ + Ensure creator_id exists, is active, and references a creator. + + A creator is a 'person' entity that has the 'creator' property. + """ + ensure_entity_active(cursor, creator_id) + if not is_creator(cursor, creator_id): + raise ValueError("creator_id must reference a creator") + + def insert_creator(cursor, name, public_key): """ + Create a creator. + Creators are persons with property 'creator' in the property table. """ cursor.execute( @@ -54,7 +90,12 @@ def insert_creator(cursor, name, public_key): def enroll_person(cursor, name, public_key, creator_id): - ensure_entity_active(cursor, creator_id) + """ + Enroll a new person under a creator. + + creator_id must refer to an active creator (person + 'creator' property). + """ + ensure_creator(cursor, creator_id) cursor.execute( """ @@ -71,7 +112,13 @@ def enroll_person(cursor, name, public_key, creator_id): def create_group(cursor, name, public_key, creator_id, ca_reference): - ensure_entity_active(cursor, creator_id) + """ + Create a group under a creator. + + creator_id must refer to an active creator (person + 'creator' property). + Groups must define a non-empty ca_reference. + """ + ensure_creator(cursor, creator_id) _validate_ca_reference_for_group(ca_reference) cursor.execute( @@ -98,6 +145,8 @@ def get_entity(cursor, entity_id): def set_entity_status(cursor, entity_id, status, changed_by): """ + Update entity status. + Only active entities can change status. Once revoked, immutable. """ ensure_entity_active(cursor, entity_id) diff --git a/ca_core/group_member.py b/ca_core/group_member.py index c00cb63..d7a108c 100644 --- a/ca_core/group_member.py +++ b/ca_core/group_member.py @@ -2,32 +2,67 @@ from db_logging import log_change from entity import ensure_entity_active +def _get_entity_type(cursor, entity_id): + cursor.execute("SELECT type FROM entity WHERE id = %s", (entity_id,)) + row = cursor.fetchone() + return row["type"] if row else None + + +def _validate_role(role): + if not isinstance(role, str): + raise TypeError("role must be a string") + r = role.strip() + if not r: + raise ValueError("role must be a non-empty string") + if len(r) > 10: + raise ValueError("role must be at most 10 characters") + return r + + def add_group_member(cursor, group_id, member_id, role): + """Add a member to a group. + + Rules: + - group_id must reference an active entity of type 'group' + - member_id must reference an active entity (person/device/group) + - role must be a non-empty string with max length 10 + - duplicates are rejected + """ ensure_entity_active(cursor, group_id) ensure_entity_active(cursor, member_id) + if _get_entity_type(cursor, group_id) != "group": + raise ValueError("group_id must reference an entity of type 'group'") + + r = _validate_role(role) + + cursor.execute( + "SELECT 1 FROM group_member WHERE group_id = %s AND member_id = %s", + (group_id, member_id), + ) + if cursor.fetchone() is not None: + raise ValueError("Member is already in the group") + cursor.execute( """ INSERT INTO group_member (group_id, member_id, role) VALUES (%s, %s, %s) """, - (group_id, member_id, role) + (group_id, member_id, r), ) - log_change( - cursor, - f"Added member {member_id} to group {group_id} as {role}" - ) + log_change(cursor, f"Added member {member_id} to group {group_id} as {r}") def get_members_of_group(cursor, group_id): + ensure_entity_active(cursor, group_id) cursor.execute( """ SELECT member_id, role FROM group_member WHERE group_id = %s + ORDER BY member_id """, - (group_id,) + (group_id,), ) return cursor.fetchall() - diff --git a/ca_core/metadata.py b/ca_core/metadata.py index cf301a5..c1c50c6 100644 --- a/ca_core/metadata.py +++ b/ca_core/metadata.py @@ -1,12 +1,29 @@ from db_logging import log_change +def _ensure_singleton_row(cursor): + """Ensure exactly one metadata row exists. + + The metadata table is treated as a singleton at the application level. + Setters must ONLY update the relevant column(s) and must not wipe others. + + If the table is empty, a single default row is inserted. + If the table contains more than one row, we raise to avoid ambiguous reads. + """ + cursor.execute("SELECT COUNT(*) AS cnt FROM metadata") + row = cursor.fetchone() + cnt = int(row["cnt"]) if row and row["cnt"] is not None else 0 + + if cnt == 0: + # Rely on column defaults (e.g., defense_p default false) and nullable columns. + cursor.execute("INSERT INTO metadata DEFAULT VALUES") + elif cnt > 1: + raise ValueError("metadata table must contain exactly one row") + + def set_name(cursor, name): - cursor.execute("DELETE FROM metadata") - cursor.execute( - "INSERT INTO metadata (name) VALUES (%s)", - (name,) - ) + _ensure_singleton_row(cursor) + cursor.execute("UPDATE metadata SET name = %s", (name,)) log_change(cursor, f"Updated metadata name to {name}") @@ -17,11 +34,8 @@ def get_name(cursor): def set_comment(cursor, comment): - cursor.execute("DELETE FROM metadata") - cursor.execute( - "INSERT INTO metadata (comment) VALUES (%s)", - (comment,) - ) + _ensure_singleton_row(cursor) + cursor.execute("UPDATE metadata SET comment = %s", (comment,)) log_change(cursor, f"Updated metadata comment to {comment}") @@ -32,13 +46,10 @@ def get_comment(cursor): def set_keys(cursor, public_key, private_key): - cursor.execute("DELETE FROM metadata") + _ensure_singleton_row(cursor) cursor.execute( - """ - INSERT INTO metadata (public_key, private_key) - VALUES (%s, %s) - """, - (public_key, private_key) + "UPDATE metadata SET public_key = %s, private_key = %s", + (public_key, private_key), ) log_change(cursor, "Updated metadata keys") @@ -59,13 +70,10 @@ 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. + This setter updates ONLY defense_p and preserves all other columns. """ - cursor.execute("DELETE FROM metadata") - cursor.execute( - "INSERT INTO metadata (defense_p) VALUES (%s)", - (defense_p,) - ) + _ensure_singleton_row(cursor) + cursor.execute("UPDATE metadata SET defense_p = %s", (defense_p,)) log_change(cursor, f"Updated metadata defense_p to {defense_p}") diff --git a/ca_core/property.py b/ca_core/property.py index bf52d73..10d65b7 100644 --- a/ca_core/property.py +++ b/ca_core/property.py @@ -68,7 +68,7 @@ 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", + "SELECT property_name FROM property WHERE id = %s ORDER BY property_name", (entity_id,), ) rows = cursor.fetchall() diff --git a/tests/__pycache__/test_entity.cpython-313.pyc b/tests/__pycache__/test_entity.cpython-313.pyc index f39ebef7e47d339de2c8db0a2d72eac4651f89f2..c72f3417aa10b520bd7f9a761f847eab234b51cc 100644 GIT binary patch delta 1764 zcmaKs%}*Og7{+()nq4ouUSk`xwj1I$PU1DC9~4Mhi7!(^(?$fcwB@6)7_$%)$0oDu zDz(~z+Ka0ys-iih2ik^H>=UkX$*nYpiX0*p9O`Ptp?^VD5k--5>de|(2p?H#pPij~ z=bd?fdS)KHwRGO`nBy1%J*Qh%5>K7KIC2*93wX;i5%LqjA+bn#i9_m?97yXV9;r)m z0{2{<=(_vN(!!F3yUqM93aX5k%)^J=6#UuBz~7y(!J@~$*hb>)1Q8_W2+=yAv%Nrx zX=%`VS&3iKsfEn+73K30(FWR35NO8|K%z4-RaJmC3^Iz&Bvd(GnFb^@s9KN&gMh<< z9!*g-c{f7V@RaZ{>g`Ku@H5_3zi;ppoZ<{D^7SVt*G5bA7jw*CH2XdOEx%k-7Q{_K z%!gLPg>dnMlF+q9cf%ADu>3>7PU+0FU+2Ux)hY126C5u;`fSLqBp%-ue z@xK+W1*hx9Tx)i%(+|>_1ruMtUSs@^=yZj2|45eZ?PhkhMk?+_ zI)*x`NDZPPLO*^q(m7)jZ^Jb23myv72$;e-KgJNJOsj3XeP#ElP4}tW+MVkw*Neiv zch|CiNE0RZWR5;95wIA{Nxt1EMj8(iaZ zc8iN_v#v55++>5dJ$Hl^A>VcH!*$;yR(fhB98F~|@+TL0rm`s0Z-G#hw%(_isF!-q zn=~3UyxDpP)0?Ur9ef{UP?adS4mU(qVJqaqugJIJ;jv3--RT%bv`J@vB5kTw~@hy1bTe55$P zCa!<_h#fnG*2$>ddf(1O8>j*?AbQ{fZ>)P5Wdr!80C6nySX3-oEZ<=H4vAhb%c>UB z5^*`EX&^D3LEDyPa03oCyi6uxs^P+k9f7vB?szS>9&(4aVr8thZlpHbF!{t`vyGGR q*M=|28?ZwVm>*GAe4nT@P!=TLBuS2wKZMKvrVW476T9 zGWcooPj**IoVnKwO+OSwtmR+>w#dWQN9dDea3=+Fuz!Jg_bR(U~U! diff --git a/tests/__pycache__/test_group.cpython-313.pyc b/tests/__pycache__/test_group.cpython-313.pyc index d411805465e49877974aff7f8f3dd843ce2100b9..6861895208ad8ba8a4533f1e45e1a4e3c720a765 100644 GIT binary patch delta 1556 zcmbtUO-vg{7~Qo2uh-t?$7}qjrfv}=UaM6U;noyLRP2@{rN*)$xESIB-Ud5hTVop$ zE>u0arsYGm2ODi9G$T@0>BBb=vZ`O7Kp~F} zcD|X_dvEt$KYheM<~Wvu`oz5+yV>zGe_ch#@%O3;vz0RqhL!CI+vMh+Z5^wYVeZb+$!Ufbsi+mWRhs?T1t{NsqE}k>Drjg`X!W3CKQsXk^V7R_nbW= zYa8?#^e(h_FlY$=kcWVcfR{j=z*Pbt5*P+>G$98W`zTm|&caaECo}UBilmfH)8y6c zP>3UQwv3d{M6RG@HWi6QBa2zMo0V#W{5D==&8z=C8HUyARe;Z_s?k<4iY24C(Yxi| zbmylF7s^Ioj;#vTig2nVoZ1k!%$w%?K*3s^EDNEWwrYBwrfaK zZ;R}ZTQgTz0%(fTz@>)ku~nt$`z^lS(f!L-r0mrH>Wz`%J;paWsD7RLjK((%J<|>21d!7M{@r9U35p*O>>62`S%^hvGO=VrK1qxa zdx4#U#_3-HUf}?PrDEtQ8G3GIZZEGd=WTc6MRE61q-^*!$GqGgIBc}@9eer_Mex5` zD82(xyJR)NqH zQw-27PVl|_QE~Ad-ehWnzDgajh4MJbI72c%s`bfiJ>%YNsu6Lo%qU6`sv(qjr3HlY zj{9Y8E$D|P{oY#2>&iR}b6q2%O!u3LGSO^mAvQOYks^!I;w&V3K9-6gDK5=rq^Rst z0{hBm67WjtuK|v$HdW{=8$X8JzMz`*?qt!rJ3d`DTt2Q^e}OGtEDIAk?L$VWFx@4F z@4gkiJ-m;E=^l zPvJGou&^&M%|Uvw_(><0EkDpd;uUMeGE37z`Wth_BHp)%PblRUwL+7B E0UF}32LJ#7 delta 103 zcmZ4D{mGQ?GcPX}0}wnYSdh7mXCq%f6Jx~Wc}(GKMZD?Unmn6%ncs!`*{x-c>ZGtQ8>E~#}2c5X&Kpbo9cbGSGr7jW@7q%&wT``zLSC`v6(EhDl!2P z(jdYINZjH|EC#A52`MT~E%F74nt}+k$q(5?4E0J