tiny-ca 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tiny_ca/db/base_db.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+
3
+ Abstract base class that defines the contract for all certificate registry
4
+ database adapters.
5
+
6
+ Any class that inherits from ``BaseDB`` and implements all six abstract methods
7
+ can be used wherever the application expects a database layer, without requiring
8
+ any changes to the calling code (LSP / DIP).
9
+
10
+ SOLID notes
11
+ -----------
12
+ SRP : ``BaseDB`` declares *what* the registry must be able to do; concrete
13
+ subclasses decide *how*.
14
+ OCP : New query operations are added by extending ``BaseDB``; existing
15
+ implementations are not modified.
16
+ LSP : Every concrete subclass must honour the documented pre- and post-conditions
17
+ of each method so callers can substitute implementations freely.
18
+ ISP : The interface is intentionally narrow — only operations that callers
19
+ actually need are declared here.
20
+ DIP : High-level modules (e.g. ``CertLifecycleManager``) depend on ``BaseDB``,
21
+ never on ``SyncDBHandler`` or any other concrete implementation.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from abc import ABC, abstractmethod
27
+ from collections.abc import Generator
28
+
29
+ from cryptography import x509
30
+
31
+ from tiny_ca.const import CertType
32
+
33
+ from .models import CertificateRecord
34
+
35
+
36
+ class BaseDB(ABC):
37
+ """
38
+ Abstract contract for the certificate registry database adapter.
39
+
40
+ Defines all operations that the application layer requires from the
41
+ persistence tier. Concrete implementations (e.g. ``SyncDBHandler``)
42
+ provide the actual SQL queries and transaction management.
43
+
44
+ All methods that return a ``CertificateRecord`` must return ``None``
45
+ (not raise an exception) when the requested record simply does not exist.
46
+ Exceptions are reserved for genuine infrastructure failures (connection
47
+ errors, constraint violations, etc.).
48
+ """
49
+
50
+ @abstractmethod
51
+ def get_by_serial(self, serial: int) -> CertificateRecord | None:
52
+ """
53
+ Retrieve a certificate record by its X.509 serial number.
54
+
55
+ Parameters
56
+ ----------
57
+ serial : int
58
+ Integer serial number to look up. Implementations are responsible
59
+ for any type conversion required by the underlying storage format
60
+ (e.g. converting to ``str`` for string-typed database columns).
61
+
62
+ Returns
63
+ -------
64
+ CertificateRecord | None
65
+ The matching record regardless of its current status (VALID,
66
+ REVOKED, EXPIRED), or ``None`` if no record exists for *serial*.
67
+ """
68
+
69
+ @abstractmethod
70
+ def get_by_name(self, common_name: str) -> CertificateRecord | None:
71
+ """
72
+ Retrieve the currently active certificate record for a given Common Name.
73
+
74
+ Implementations must filter to only ``VALID`` records so that the
75
+ caller always receives the live certificate or ``None`` — never a
76
+ revoked or expired one.
77
+
78
+ Parameters
79
+ ----------
80
+ common_name : str
81
+ The CN (Common Name) field from the certificate Subject to look up.
82
+
83
+ Returns
84
+ -------
85
+ CertificateRecord | None
86
+ The active VALID record for *common_name*, or ``None`` if no such
87
+ record exists.
88
+ """
89
+
90
+ @abstractmethod
91
+ def register_cert_in_db(
92
+ self,
93
+ cert: x509.Certificate,
94
+ uuid: str,
95
+ key_type: CertType = CertType.DEVICE,
96
+ ) -> bool:
97
+ """
98
+ Persist a newly issued certificate to the registry.
99
+
100
+ Creates a new record with ``status=VALID`` populated from the
101
+ certificate metadata. The implementation must extract the CN from
102
+ ``cert.subject`` and store the full PEM encoding for later retrieval.
103
+
104
+ Parameters
105
+ ----------
106
+ cert : x509.Certificate
107
+ The issued X.509 certificate object to register.
108
+ uuid : str
109
+ UUID that identifies the filesystem folder containing the
110
+ corresponding ``.pem``, ``.key``, and ``.csr`` artefact files.
111
+ key_type : CertType
112
+ Certificate category (CA, USER, SERVICE, DEVICE, INTERNAL).
113
+ Default: ``CertType.DEVICE``.
114
+
115
+ Returns
116
+ -------
117
+ bool
118
+ ``True`` if the record was persisted successfully;
119
+ ``False`` if the operation failed (the implementation must log
120
+ the reason and roll back any partial changes).
121
+ """
122
+
123
+ @abstractmethod
124
+ def revoke_certificate(
125
+ self,
126
+ serial_number: int,
127
+ reason: x509.ReasonFlags = x509.ReasonFlags.unspecified,
128
+ ) -> tuple[bool, object]:
129
+ """
130
+ Mark a certificate as revoked and record the reason and timestamp.
131
+
132
+ Implementations must:
133
+ 1. Look up the record by *serial_number* filtered to ``status=VALID``.
134
+ 2. If not found, return ``(False, <NOT_FOUND status>)`` without error.
135
+ 3. Update ``status``, ``revocation_reason``, and ``revocation_date``.
136
+ 4. Commit atomically; roll back on any exception.
137
+
138
+ Parameters
139
+ ----------
140
+ serial_number : int
141
+ Serial number of the certificate to revoke.
142
+ reason : x509.ReasonFlags
143
+ RFC 5280 §5.3.1 revocation reason code.
144
+ Default: ``x509.ReasonFlags.unspecified`` (code 0).
145
+
146
+ Returns
147
+ -------
148
+ tuple[bool, object]
149
+ ``(success, status_value)`` where *status_value* is implementation-
150
+ defined (typically a ``RevokeStatus`` enum member).
151
+ ``True`` indicates the revocation was committed; ``False`` means it
152
+ was not (reason encoded in *status_value*).
153
+ """
154
+
155
+ @abstractmethod
156
+ def get_revoked_certificates(self) -> Generator[CertificateRecord, None, None]:
157
+ """
158
+ Yield certificate records that should appear in the current CRL.
159
+
160
+ Implementations define their own freshness window (e.g. only records
161
+ revoked within the past 365 days and not yet expired), but must yield
162
+ objects that expose at minimum:
163
+
164
+ - ``serial_number`` — castable to ``int``
165
+ - ``revocation_date`` — a ``datetime`` object
166
+ - ``revocation_reason`` — an integer RFC 5280 reason code
167
+
168
+ Yields
169
+ ------
170
+ CertificateRecord
171
+ Records (or row-like objects) for each revoked certificate that
172
+ falls within the implementation's CRL inclusion window.
173
+ """
tiny_ca/db/const.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ db/const.py
3
+
4
+ Shared constants for the database layer.
5
+
6
+ Currently exports ``RevokeStatus``, the value object that encodes the
7
+ possible outcomes of a certificate revocation attempt. Keeping it in a
8
+ dedicated module prevents circular imports between ``base_db``, the ORM
9
+ models, and the concrete handler implementations.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from enum import Enum, StrEnum
15
+
16
+
17
+ class RevokeStatus(Enum):
18
+ """
19
+ Enumerated outcomes for a certificate revocation attempt.
20
+
21
+ Used as the second element of the ``(bool, RevokeStatus)`` tuple returned
22
+ by ``BaseDB.revoke_certificate`` implementations. The boolean indicates
23
+ overall success; this enum provides the machine-readable reason when the
24
+ operation did not succeed, and a confirmation token when it did.
25
+
26
+ Members
27
+ -------
28
+ NOT_FOUND :
29
+ No active (``VALID``) certificate with the requested serial number
30
+ exists in the registry. The certificate may already be revoked,
31
+ expired, or was never registered.
32
+ UNKNOWN_ERROR :
33
+ An unexpected internal error (e.g. database constraint violation,
34
+ connection failure) prevented the revocation. The implementation must
35
+ log the underlying exception before returning this status.
36
+ OK :
37
+ The revocation was committed successfully. The certificate record has
38
+ been updated with ``status=REVOKED``, a ``revocation_date``, and the
39
+ provided ``revocation_reason``.
40
+
41
+ Examples
42
+ --------
43
+ >>> success, status = db.revoke_certificate(serial=12345, reason=ReasonFlags.key_compromise)
44
+ >>> if not success:
45
+ ... if status == RevokeStatus.NOT_FOUND:
46
+ ... logger.warning("Certificate not found")
47
+ ... else:
48
+ ... logger.error("Internal revocation error")
49
+ """
50
+
51
+ NOT_FOUND = (
52
+ "The certificate was not revoked because there is no valid certificate "
53
+ "with the specified serial number."
54
+ )
55
+ UNKNOWN_ERROR = (
56
+ "The certificate was not revoked due to an internal error. "
57
+ "Please review the service logs."
58
+ )
59
+ OK = "success"
60
+
61
+
62
+ class CertificateStatus(StrEnum):
63
+ """
64
+ Lifecycle state of a certificate record in the registry.
65
+
66
+ Stored as a lowercase string in the ``status`` column of
67
+ ``CertificateRecord`` so the value is human-readable in raw SQL output.
68
+
69
+ Members
70
+ -------
71
+ VALID :
72
+ The certificate was issued successfully and has not been revoked or
73
+ expired. Active certificates used for authentication or encryption
74
+ are expected to be in this state.
75
+ REVOKED :
76
+ The certificate was explicitly revoked before its natural expiry.
77
+ The ``revocation_date`` and ``revocation_reason`` columns on the
78
+ corresponding ``CertificateRecord`` row must be non-null.
79
+ EXPIRED :
80
+ The certificate's ``not_valid_after`` date has passed. This status
81
+ may be set by a background job; alternatively, callers can detect
82
+ expiry by comparing ``not_valid_after`` to the current time.
83
+ UNKNOWN :
84
+ The status could not be determined, typically because no record was
85
+ found for the requested serial number. Used as a safe sentinel value
86
+ by ``CertLifecycleManager.get_certificate_status``.
87
+ """
88
+
89
+ VALID = "valid"
90
+ REVOKED = "revoked"
91
+ EXPIRED = "expired"
92
+ UNKNOWN = "unknown"
tiny_ca/db/models.py ADDED
@@ -0,0 +1,144 @@
1
+ """
2
+
3
+ SQLAlchemy ORM models and status enumerations for the certificate registry.
4
+
5
+ Module-level contents
6
+ ---------------------
7
+ ``CertificateStatus`` — ``StrEnum`` representing the lifecycle state of a
8
+ certificate row.
9
+ ``CertificateRecord`` — ORM-mapped table that stores all metadata for issued,
10
+ revoked, and expired certificates.
11
+
12
+ Design notes
13
+ ------------
14
+ - ``CertificateStatus`` uses ``StrEnum`` so that values are stored as plain
15
+ strings in the database, making the column human-readable and compatible
16
+ with non-Python tooling that queries the database directly.
17
+ - ``CertificateRecord`` stores the full PEM-encoded public certificate so the
18
+ certificate can be reconstructed independently of the filesystem artefacts.
19
+ - ``serial_number`` is stored as ``String`` (not ``Integer``) because X.509
20
+ serial numbers can be up to 20 bytes / 160 bits, exceeding the range of a
21
+ 64-bit SQL integer on most backends.
22
+ - ``uuid`` links the database record to the filesystem folder managed by
23
+ ``BaseStorage``, enabling clean deletion of both the DB row and the
24
+ corresponding files together.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from sqlalchemy import Column, DateTime, Integer, String
30
+ from sqlalchemy.orm import Mapped, declarative_base
31
+
32
+ from tiny_ca.const import CertType
33
+
34
+ from .const import CertificateStatus
35
+
36
+ Base = declarative_base()
37
+
38
+
39
+ class CertificateRecord(Base): # type: ignore
40
+ """
41
+ ORM model for a single certificate entry in the registry.
42
+
43
+ Maps to the ``certificates`` table. Each row represents one certificate
44
+ that has been issued by the CA, regardless of its current lifecycle state.
45
+
46
+ Columns
47
+ -------
48
+ id : int
49
+ Auto-incremented surrogate primary key. Not exposed to application
50
+ code; use ``serial_number`` as the business key.
51
+ serial_number : str
52
+ X.509 serial number stored as a decimal string. Unique and indexed.
53
+ String storage avoids integer overflow for 160-bit serials (RFC 5280).
54
+ common_name : str
55
+ Common Name (CN) extracted from the certificate Subject at issuance
56
+ time. Not unique; the same CN may appear across different certificate
57
+ generations (e.g. after rotation).
58
+ status : str
59
+ Current lifecycle state. One of the ``CertificateStatus`` values:
60
+ ``"valid"``, ``"revoked"``, ``"expired"``, or ``"unknown"``.
61
+ Defaults to ``CertificateStatus.VALID`` on insertion.
62
+ not_valid_before : datetime
63
+ Start of the certificate's validity period (UTC, naive datetime as
64
+ stored by SQLAlchemy's ``DateTime`` column type).
65
+ not_valid_after : datetime
66
+ End of the certificate's validity period (UTC, naive datetime).
67
+ Indexed to allow efficient queries for expired certificates.
68
+ key_type : str
69
+ Certificate category stored as the ``CertType`` enum's string value
70
+ (e.g. ``"ca"``, ``"device"``, ``"service"``).
71
+ Defaults to ``CertType.DEVICE.value``.
72
+ certificate_pem : str
73
+ Full PEM-encoded public certificate. Allows reconstruction of the
74
+ ``x509.Certificate`` object without accessing the filesystem.
75
+ revocation_date : datetime | None
76
+ UTC timestamp at which the certificate was revoked. ``None`` for
77
+ non-revoked certificates.
78
+ revocation_reason : int | None
79
+ RFC 5280 §5.3.1 revocation reason code stored as an integer.
80
+ ``None`` for non-revoked certificates. Maps to the integer value
81
+ of the corresponding ``x509.ReasonFlags`` member.
82
+ uuid : str | None
83
+ UUID string that identifies the filesystem folder (managed by
84
+ ``BaseStorage``) holding the ``.pem``, ``.key``, and ``.csr`` files
85
+ for this certificate. ``None`` if no filesystem artefacts exist.
86
+ """
87
+
88
+ __tablename__ = "certificates"
89
+
90
+ id = Column(Integer, primary_key=True)
91
+
92
+ serial_number = Column(
93
+ String,
94
+ unique=True,
95
+ nullable=False,
96
+ index=True,
97
+ comment="X.509 serial number as a decimal string (up to 160-bit value).",
98
+ )
99
+ common_name = Column(
100
+ String,
101
+ nullable=False,
102
+ comment="CN extracted from the certificate Subject at issuance time.",
103
+ )
104
+ status: Mapped[str] = Column( # type: ignore
105
+ String,
106
+ default=CertificateStatus.VALID,
107
+ comment="Lifecycle state: valid | revoked | expired | unknown.",
108
+ )
109
+ not_valid_before = Column(
110
+ DateTime,
111
+ nullable=False,
112
+ comment="Start of the certificate validity window (UTC).",
113
+ )
114
+ not_valid_after = Column(
115
+ DateTime,
116
+ nullable=False,
117
+ index=True,
118
+ comment="End of the certificate validity window (UTC). Indexed for expiry queries.",
119
+ )
120
+ key_type = Column(
121
+ String,
122
+ default=CertType.DEVICE.value,
123
+ comment="Certificate category: ca | device | service | user | internal.",
124
+ )
125
+ certificate_pem = Column(
126
+ String,
127
+ nullable=False,
128
+ comment="Full PEM-encoded public certificate for offline reconstruction.",
129
+ )
130
+ revocation_date = Column(
131
+ DateTime,
132
+ nullable=True,
133
+ comment="UTC timestamp of revocation. NULL for non-revoked certificates.",
134
+ )
135
+ revocation_reason: Mapped[str] = Column( # type: ignore
136
+ String,
137
+ nullable=True,
138
+ comment="RFC 5280 §5.3.1 reason code integer. NULL for non-revoked certificates.",
139
+ )
140
+ uuid = Column(
141
+ String,
142
+ unique=True,
143
+ comment="Filesystem folder UUID linking this record to its storage artefacts.",
144
+ )