Skip to content

Commit 417d0d4

Browse files
Merge pull request #308 from minvws/feat/oin-support
feat: oin support and depracated ura_number
2 parents c69d1ab + dd17227 commit 417d0d4

32 files changed

Lines changed: 491 additions & 292 deletions

app/db/decorator.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
from typing import Any, Type, Dict
1+
from typing import Callable, Type, Dict, TypeVar
22

33
from app.db.entities.base import Base
44
from app.db.repositories.repository_base import RepositoryBase
55

6+
T = TypeVar("T", bound=Type[RepositoryBase])
7+
68
repository_registry: Dict[Type[Base], Type[RepositoryBase]] = {}
79

810

9-
def repository(model_class: Type[Base]) -> Any:
10-
def decorator(repo_class: Type[RepositoryBase]) -> Type[RepositoryBase]:
11+
def repository(model_class: Type[Base]) -> Callable[..., T]:
12+
def decorator(repo_class: T) -> T:
1113
"""
1214
Decorator to register a repository for a model class
1315

app/db/entities/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,11 @@ class Base(DeclarativeBase):
1111
def to_dict(self) -> Dict[str, Any]:
1212
return {col.name: getattr(self, col.name) for col in self.__table__.columns}
1313

14+
def __repr__(self) -> str:
15+
props = ", ".join(
16+
[f"{k}={self.__getattribute__(k)}" for k in self.__table__.columns.keys()]
17+
)
18+
return f"<{self.__class__.__name__}=({props})>"
19+
1420

1521
TBase = TypeVar("TBase", bound=Base, covariant=True)

app/db/entities/hsm_key_versions.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
from datetime import datetime
12
import uuid
2-
from typing import Any
3+
from typing import Any, Optional
34

4-
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer
5+
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer
56
from sqlalchemy.dialects.postgresql import UUID
67
from sqlalchemy.orm import Mapped, mapped_column, relationship
78

@@ -24,22 +25,25 @@ class HsmKeyVersion(Base):
2425
ForeignKey("organization.id", ondelete="CASCADE"),
2526
nullable=False,
2627
)
27-
version = Column(Integer, nullable=False)
28-
from_dt = Column(DateTime(timezone=True), nullable=False)
29-
until_dt = Column(DateTime(timezone=True), nullable=True)
30-
removed = Column(Boolean, nullable=False, server_default="false")
28+
version: Mapped[int] = mapped_column("version", Integer, nullable=False)
29+
from_dt: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
30+
until_dt: Mapped[Optional[datetime]] = mapped_column(
31+
DateTime(timezone=True), nullable=True
32+
)
33+
removed: Mapped[bool] = mapped_column(
34+
Boolean, nullable=False, server_default="false"
35+
)
3136

3237
organization: Mapped[Organization] = relationship("Organization")
3338

3439
@property
35-
def ura(self) -> str:
36-
"""Convenience accessor exposing the organization's URA."""
37-
return str(self.organization.ura)
40+
def oin(self) -> str:
41+
return self.organization.oin
3842

3943
def to_dict(self) -> dict[str, Any]:
4044
return {
4145
"id": str(self.id),
42-
"ura": self.ura,
46+
"oin": self.oin,
4347
"version": self.version,
4448
"from_dt": self.from_dt,
4549
"until_dt": self.until_dt,

app/db/entities/organization.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import uuid
22
from typing import Any
33

4-
from sqlalchemy import Column, String
4+
from sqlalchemy import String
55
from sqlalchemy.dialects.postgresql import UUID
66
from sqlalchemy.orm import Mapped, mapped_column, relationship
77

@@ -18,9 +18,9 @@ class Organization(Base):
1818
id: Mapped[uuid.UUID] = mapped_column(
1919
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
2020
)
21-
ura = Column(String, unique=True, nullable=False)
22-
name = Column(String, nullable=False)
23-
max_rid_usage = Column(String, nullable=False)
21+
oin: Mapped[str] = mapped_column("oin", String, unique=True)
22+
name: Mapped[str] = mapped_column("name", String)
23+
max_rid_usage: Mapped[str] = mapped_column("max_rid_usage", String)
2424

2525
keys = relationship(
2626
"OrganizationKey",
@@ -30,7 +30,7 @@ class Organization(Base):
3030

3131
def to_dict(self) -> dict[str, Any]:
3232
return {
33-
"ura": self.ura,
33+
"oin": self.oin,
3434
"name": self.name,
3535
"max_rid_usage": self.max_rid_usage,
3636
}

app/db/entities/organization_key.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import uuid
22
from typing import Any
33

4-
from sqlalchemy import Column, Text, ForeignKey
4+
from pyoprf import List
5+
from sqlalchemy import Text, ForeignKey
56
from sqlalchemy.dialects.postgresql import UUID, JSONB
67
from sqlalchemy.orm import mapped_column, Mapped, relationship
78

@@ -23,8 +24,10 @@ class OrganizationKey(Base):
2324
ForeignKey("organization.id", ondelete="CASCADE"),
2425
nullable=False,
2526
)
26-
scope = Column(JSONB, nullable=False, server_default="{}")
27-
key_data = Column(Text, nullable=False)
27+
scope: Mapped[List[str]] = mapped_column(
28+
"scope", JSONB, nullable=False, server_default="{}"
29+
)
30+
key_data: Mapped[str] = mapped_column("key_data", Text, nullable=False)
2831

2932
organization = relationship("Organization", back_populates="keys")
3033

app/db/repositories/hsm_key_version_repository.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@
1717
@repository(HsmKeyVersion)
1818
class HsmKeyVersionRepository(RepositoryBase):
1919
def get_active_versions(
20-
self, at: datetime, ura: Optional[str] = None
20+
self, at: datetime, oin: Optional[str] = None
2121
) -> Sequence[HsmKeyVersion]:
2222
"""
2323
Returns all key versions that are active at the given moment, i.e. not
2424
removed, already started (from_dt <= at) and not yet ended (until_dt is
25-
unset or still in the future). When a URA is supplied, only the versions
25+
unset or still in the future). When a OIN is supplied, only the versions
2626
belonging to that organization are returned.
2727
"""
2828
query = (
@@ -34,20 +34,20 @@ def get_active_versions(
3434
or_(HsmKeyVersion.until_dt.is_(None), HsmKeyVersion.until_dt >= at),
3535
)
3636
)
37-
if ura is not None:
38-
query = query.join(Organization).where(Organization.ura == ura)
37+
if oin is not None:
38+
query = query.join(Organization).where(Organization.oin == oin)
3939
return self.db_session.execute(query).scalars().all()
4040

41-
def get_by_ura(self, ura: str) -> Sequence[HsmKeyVersion]:
41+
def get_by_oin(self, oin: str) -> Sequence[HsmKeyVersion]:
4242
"""
43-
Returns all key versions belonging to the organization with the given URA,
43+
Returns all key versions belonging to the organization with the given OIN,
4444
regardless of date or removed state, ordered by version number.
4545
"""
4646
query = (
4747
select(HsmKeyVersion)
4848
.options(joinedload(HsmKeyVersion.organization))
4949
.join(Organization)
50-
.where(Organization.ura == ura)
50+
.where(Organization.oin == oin)
5151
.order_by(HsmKeyVersion.version)
5252
)
5353
return self.db_session.execute(query).scalars().all()
@@ -56,7 +56,7 @@ def get_expired_versions(self, at: datetime) -> Sequence[HsmKeyVersion]:
5656
"""
5757
Returns all key versions that have passed their end date (until_dt is set
5858
and in the past) but have not been removed yet. The organization is eagerly
59-
loaded so its URA stays available after the session is closed.
59+
loaded so its OIN stays available after the session is closed.
6060
"""
6161
query = (
6262
select(HsmKeyVersion)
@@ -130,8 +130,8 @@ def update(
130130
logger.warning("hsm key version with id %s does not exist", version_id)
131131
return None
132132

133-
entry.until_dt = until_dt # type: ignore
134-
entry.removed = removed # type: ignore
133+
entry.until_dt = until_dt
134+
entry.removed = removed
135135
self.db_session.add(entry)
136136
self.db_session.flush()
137137

@@ -148,7 +148,7 @@ def mark_removed(self, version_id: uuid.UUID) -> Optional[HsmKeyVersion]:
148148
logger.warning("hsm key version with id %s does not exist", version_id)
149149
return None
150150

151-
entry.removed = True # type: ignore
151+
entry.removed = True
152152
self.db_session.add(entry)
153153
self.db_session.flush()
154154

app/db/repositories/org_key_repository.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ def update(
7171
if entry is None:
7272
raise ValueError("Key entry not found")
7373

74-
entry.scope = scope # type: ignore
75-
entry.key_data = key_data # type: ignore
74+
entry.scope = scope
75+
entry.key_data = key_data
7676
self.db_session.add(entry)
7777
return entry
7878

app/db/repositories/org_repository.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,26 @@
1212

1313
@repository(Organization)
1414
class OrgRepository(RepositoryBase):
15-
def get_by_ura(self, ura: str) -> Optional[Organization]:
15+
def get_by_oin(self, oin: str) -> Optional[Organization]:
1616
"""
1717
Fetches the organization by its unique ID.
1818
"""
19-
query = select(Organization).where(Organization.ura == ura)
19+
query = select(Organization).where(Organization.oin == oin)
2020
return self.db_session.execute(query).scalars().first()
2121

22-
def create(self, ura: str, name: str, max_usage_level: str) -> Organization:
22+
def create(self, oin: str, name: str, max_usage_level: str) -> Organization:
2323
"""
2424
Creates a new org entry.
2525
"""
2626
entry = Organization(
27-
ura=ura,
27+
oin=oin,
2828
name=name,
2929
max_rid_usage=max_usage_level,
3030
)
3131
self.db_session.add(entry)
3232
self.db_session.flush()
3333

34-
logger.info("created organization with URA %s and name %r", ura, name)
34+
logger.info("created organization with OIN %s and name %r", oin, name)
3535
return entry
3636

3737
def update(
@@ -45,8 +45,8 @@ def update(
4545
logger.error(f"organization with ID {org_id} does not exist")
4646
raise ValueError("Organization not found")
4747

48-
org.name = name # type: ignore
49-
org.max_rid_usage = max_usage_level # type: ignore
48+
org.name = name
49+
org.max_rid_usage = max_usage_level
5050
self.db_session.add(org)
5151
return org
5252

app/db/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def get_repository(
5252
self, repository_class: Type["repository_base.TRepositoryBase"]
5353
) -> "repository_base.TRepositoryBase":
5454
"""
55-
Returns an instantiated repository
55+
Returns an instantiated repository for the given model class
5656
"""
5757
if issubclass(repository_class, repository_base.RepositoryBase):
5858
return repository_class(self)

app/models/oin.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import re
2+
from typing import Any
3+
4+
from pydantic import GetCoreSchemaHandler
5+
from pydantic.json_schema import JsonSchemaValue
6+
from pydantic_core import core_schema
7+
8+
# An OIN is always 20 characters:
9+
# prefix (8 digits) + mainnumber (8 or 9 alphanumeric) + suffix (4 or 3 zeros)
10+
OIN_PATTERN = re.compile(r"^\d{8}(?:[A-Za-z0-9]{8}0{4}|[A-Za-z0-9]{9}0{3})$")
11+
12+
13+
class Oin:
14+
"""
15+
Value object representing a Dutch OIN (Organisatie Identificatie nummer).
16+
17+
An OIN is exactly 20 positions long (matching the PKIoverheid certificate
18+
subject serial number field) and consists of three parts:
19+
20+
- Prefix (8 positions): digits identifying the issuing authority / register.
21+
- mainnumber (8 or 9 positions): identifying number from the register.
22+
Can be alphanumeric depending on the register used.
23+
When the source is a KvK number the mainnumber is 8 positions.
24+
- Suffix (3 or 4 positions, always zeros):
25+
- "0000" when the mainnumber is 8 positions.
26+
- "000" when the mainnumber is 9 positions.
27+
28+
https://gitdocumentatie.logius.nl/publicatie/dk/oin/3.0.0/#samenstelling-oin
29+
"""
30+
31+
PREFIX_LENGTH = 8
32+
TOTAL_LENGTH = 20
33+
34+
_LONG_SUFFIX = "0000" # used when mainnumber is 8 digits
35+
_SHORT_SUFFIX = "000" # used when mainnumber is 9 digits
36+
37+
def __init__(self, value: Any) -> None:
38+
if isinstance(value, Oin):
39+
value = value.value
40+
41+
if not isinstance(value, (int, str)):
42+
raise ValueError(
43+
f"OIN must be a string or integer, got {type(value).__name__}"
44+
)
45+
46+
if isinstance(value, int) and value < 0:
47+
raise ValueError("OIN must be a positive integer")
48+
49+
str_value = str(value)
50+
51+
if not OIN_PATTERN.match(str_value):
52+
raise ValueError(
53+
f"Invalid OIN {str_value!r}. "
54+
f"Expected {self.TOTAL_LENGTH} characters structured as "
55+
f"8 digit prefix + 8/9 alphanumeric mainnumber + 4/3 trailing zeros."
56+
)
57+
58+
self.prefix = str_value[: self.PREFIX_LENGTH]
59+
self.number = str_value[self.PREFIX_LENGTH :]
60+
61+
@property
62+
def mainnumber(self) -> str:
63+
"""
64+
The identifying part of the OIN (8 or 9 characters, alphanumeric).
65+
8 characters when the suffix is "0000"; 9 characters when the suffix is "000".
66+
"""
67+
if self.number.endswith(self._LONG_SUFFIX):
68+
return self.number[
69+
: self.TOTAL_LENGTH - self.PREFIX_LENGTH - len(self._LONG_SUFFIX)
70+
]
71+
return self.number[
72+
: self.TOTAL_LENGTH - self.PREFIX_LENGTH - len(self._SHORT_SUFFIX)
73+
]
74+
75+
@property
76+
def suffix(self) -> str:
77+
"""The trailing zero-padding ("0000" when mainnumber is 8 chars, "000" when 9 chars)."""
78+
if self.number.endswith(self._LONG_SUFFIX):
79+
return self._LONG_SUFFIX
80+
return self._SHORT_SUFFIX
81+
82+
@property
83+
def value(self) -> str:
84+
return self.prefix + self.number
85+
86+
def __str__(self) -> str:
87+
return self.value
88+
89+
def __repr__(self) -> str:
90+
return f"Oin({self.prefix}, {self.number})"
91+
92+
def __eq__(self, other: Any) -> bool:
93+
if isinstance(other, Oin):
94+
return self.value == other.value
95+
return False
96+
97+
def __hash__(self) -> int:
98+
return hash(self.value)
99+
100+
@classmethod
101+
def __get_pydantic_core_schema__(
102+
cls, _source_type: Any, _handler: GetCoreSchemaHandler
103+
) -> core_schema.CoreSchema:
104+
return core_schema.no_info_plain_validator_function(
105+
cls._pydantic_validate,
106+
serialization=core_schema.to_string_ser_schema(),
107+
)
108+
109+
@classmethod
110+
def _pydantic_validate(cls, value: Any) -> "Oin":
111+
if isinstance(value, cls):
112+
return value
113+
try:
114+
return cls(value)
115+
except (ValueError, TypeError) as exc:
116+
raise ValueError(str(exc)) from exc
117+
118+
@classmethod
119+
def __get_pydantic_json_schema__(
120+
cls, _schema: core_schema.CoreSchema, _handler: Any
121+
) -> JsonSchemaValue:
122+
return {
123+
"type": "string",
124+
"pattern": r"^\d{8}(?:[A-Za-z0-9]{8}0{4}|[A-Za-z0-9]{9}0{3})$",
125+
"examples": ["00000099000000001000"],
126+
"description": (
127+
"OIN (Organisatie Identificatie nummer): 20 characters structured as "
128+
"8-digit prefix + 8/9-character alphanumeric mainnumber + 4/3 trailing zeros (suffix)."
129+
),
130+
}

0 commit comments

Comments
 (0)