SIENTIAPDE-1973: Implement per-connector OPC client certificates and remove legacy read engine. This update overhauls certificate management, transitioning from a single platform certificate to individual client certificates defined in the slot configuration. The CertProvider now downloads certificates from MinIO based on certificate_id, applying dynamic security policies (mode, policy) from the slot. The legacy READ_ENGINE=legacy option and OpcManager have been entirely removed, standardizing on the ServerReader with strict slot v2 enforcement. Write command resolution (ServerConfigResolver) now consistently uses connectors joined with opc_certificates. The CERT_BUCKET environment variable is no longer used.
93 lines
3.5 KiB
Python
93 lines
3.5 KiB
Python
"""Client certificate of a tag database connector (SIENTIAPDE-1974 D5).
|
|
|
|
Mirrors the Orchestrator's `tag_database.resolve_certificate`, which puts the
|
|
certificate in the read slot: the write lookup (ServerConfigResolver, D1) resolves a
|
|
server that is not in the pool with the same rule, so a write uses the certificate the
|
|
slot would have. Keep both in sync.
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
# security fields of an OPC UA connector: the source of its OPC security (D4)
|
|
SECURITY_FIELDS = ('mode', 'policy')
|
|
CERTIFICATE_FIELDS = ('certificate_id', 'application_uri')
|
|
CERTIFICATE_OBJECTS = ('cert', 'private_key')
|
|
STORED_OBJECT_FIELDS = ('bucket', 'object_name')
|
|
|
|
# opc_certificates lookup of a connectors aggregation: the certificate the connector's
|
|
# security.client_certificate_id points to, only when it is active and of kind client.
|
|
CERTIFICATE_LOOKUP = {
|
|
'$lookup': {
|
|
'from': 'opc_certificates',
|
|
'let': {'certificate_id': '$security.client_certificate_id'},
|
|
'pipeline': [
|
|
{
|
|
'$match': {
|
|
'$expr': {'$eq': ['$certificate_id', '$$certificate_id']},
|
|
'status': 'active',
|
|
'kind': 'client',
|
|
}
|
|
},
|
|
{
|
|
'$project': {
|
|
'_id': 0,
|
|
'certificate_id': 1,
|
|
'application_uri': 1,
|
|
'cert.bucket': 1,
|
|
'cert.object_name': 1,
|
|
'private_key.bucket': 1,
|
|
'private_key.object_name': 1,
|
|
}
|
|
},
|
|
],
|
|
'as': 'certificates',
|
|
}
|
|
}
|
|
|
|
|
|
def _is_complete(document: Any, fields: tuple[str, ...]) -> bool:
|
|
return isinstance(document, dict) and all(
|
|
document.get(field) not in (None, '') for field in fields
|
|
)
|
|
|
|
|
|
def _is_usable_certificate(certificate: Any) -> bool:
|
|
return _is_complete(certificate, CERTIFICATE_FIELDS) and all(
|
|
_is_complete(certificate.get(name), STORED_OBJECT_FIELDS) for name in CERTIFICATE_OBJECTS
|
|
)
|
|
|
|
|
|
def client_certificate_id(connector: dict[str, Any]) -> Any:
|
|
"""The connector's security.client_certificate_id (None when it has none)."""
|
|
security = connector.get('security')
|
|
return security.get('client_certificate_id') if isinstance(security, dict) else None
|
|
|
|
|
|
def has_security(connector: dict[str, Any]) -> bool:
|
|
"""Whether the connector has security.mode and security.policy (D4)."""
|
|
return _is_complete(connector.get('security'), SECURITY_FIELDS)
|
|
|
|
|
|
def resolve_certificate(connector: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""
|
|
The client certificate of a connector from CERTIFICATE_LOOKUP, or None (D5).
|
|
|
|
None means no certificate: the connection is opened without security. A connector
|
|
whose client_certificate_id is set but has no usable certificate is most likely a
|
|
registration error; the caller reports it (see client_certificate_id).
|
|
|
|
Returns:
|
|
dict[str, Any] | None: certificate_id, application_uri, and cert and private_key
|
|
as {bucket, object_name}, as in the slot
|
|
"""
|
|
for certificate in connector.get('certificates') or []:
|
|
if _is_usable_certificate(certificate):
|
|
return {
|
|
**{field: certificate[field] for field in CERTIFICATE_FIELDS},
|
|
**{
|
|
name: {field: certificate[name][field] for field in STORED_OBJECT_FIELDS}
|
|
for name in CERTIFICATE_OBJECTS
|
|
},
|
|
}
|
|
return None
|