Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/121-owned-postgresql-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- A PostgreSQL `Service` database may name an `owner` role and its `passwordRef` (`environment`, `path`, `key` in the secrets store) instead of an `application`, so a project of a server and its database alone validates; `spec.postgresql.remoteAccess` declares a listen address and the client networks allowed in. `config validate` refuses an entry with both or neither of `application` and `owner`, an `owner` without `passwordRef` or a `passwordRef` next to `application`, a reserved owner (`service_database_owner_reserved`), and a `remoteAccess` with no owner database, no firewall, the port already open, or a source of every address. Converging these declarations comes later: until then the PostgreSQL role refuses them before any task runs (#121)
106 changes: 103 additions & 3 deletions config/schemas/v1/service.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,72 @@
"maxLength": 63,
"pattern": "^[a-z][a-z0-9_]*$"
},
"postgresRoleName": {
"type": "string",
"minLength": 1,
"maxLength": 63,
"pattern": "^[a-z][a-z0-9_]*$"
},
"secretValueReference": {
"type": "object",
"properties": {
"environment": {
"$ref": "common.schema.json#/$defs/resourceId"
},
"path": {
"$ref": "common.schema.json#/$defs/absolutePath"
},
"key": {
"type": "string",
"minLength": 1,
"maxLength": 255,
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
}
},
"required": [
"environment",
"path",
"key"
],
"additionalProperties": false
},
"remoteAccess": {
"type": "object",
"properties": {
"listenAddress": {
"type": "string",
"anyOf": [
{
"format": "ipv4"
},
{
"format": "ipv6"
}
],
"not": {
"enum": [
"0.0.0.0",
"::"
]
}
},
"allowedSources": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 49
},
"minItems": 1,
"uniqueItems": true
}
},
"required": [
"listenAddress",
"allowedSources"
],
"additionalProperties": false
},
"systemdCalendar": {
"type": "string",
"minLength": 1,
Expand Down Expand Up @@ -83,16 +149,50 @@
},
"application": {
"$ref": "common.schema.json#/$defs/resourceId"
},
"owner": {
"$ref": "#/$defs/postgresRoleName"
},
"passwordRef": {
"$ref": "#/$defs/secretValueReference"
}
},
"required": [
"name",
"application"
"name"
],
"additionalProperties": false,
"oneOf": [
{
"required": [
"application"
]
},
{
"required": [
"owner"
]
}
],
"additionalProperties": false
"dependentRequired": {
"owner": [
"passwordRef"
]
},
"dependentSchemas": {
"application": {
"not": {
"required": [
"passwordRef"
]
}
}
}
},
"minItems": 1,
"uniqueItems": true
},
"remoteAccess": {
"$ref": "#/$defs/remoteAccess"
}
},
"required": [
Expand Down
67 changes: 66 additions & 1 deletion sdk/src/cloudfall/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from enum import StrEnum
from ipaddress import IPv4Address as StandardIPv4Address
from ipaddress import IPv6Network as StandardIPv6Network
from ipaddress import ip_address
from ipaddress import ip_address, ip_network
from pathlib import PurePosixPath
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -246,6 +246,11 @@ def __post_init__(self) -> None:
message = f"IP address is not canonical: {self.value!r}"
raise ValueError(message)

@property
def is_unspecified(self) -> bool:
"""Return whether the address is the wildcard of its family."""
return ip_address(self.value).is_unspecified

@classmethod
def from_boundary(cls, value: object) -> IpAddress:
"""Coerce a boundary value into an IP address."""
Expand Down Expand Up @@ -693,6 +698,66 @@ def from_boundary(cls, value: object) -> PostgresDatabaseName:
return cls(_required_string(value, "PostgreSQL database name"))


@dataclass(frozen=True, slots=True)
class PostgresRoleName:
"""Validated PostgreSQL role name safe for quoted identifiers."""

value: str

def __post_init__(self) -> None:
"""Enforce the database-name pattern state uses for roles."""
if not re.fullmatch(r"[a-z][a-z0-9_]{0,62}", self.value):
message = f"invalid PostgreSQL role name: {self.value!r}"
raise ValueError(message)

@classmethod
def from_boundary(cls, value: object) -> PostgresRoleName:
"""Coerce a boundary value into a role name."""
return cls(_required_string(value, "PostgreSQL role name"))


@dataclass(frozen=True, slots=True)
class SecretKeyName:
"""Validated key of one ``KEY=VALUE`` line in a secret source."""

value: str

def __post_init__(self) -> None:
"""Enforce the dotenv key syntax the secrets reader accepts."""
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,254}", self.value):
message = f"invalid secret key name: {self.value!r}"
raise ValueError(message)

@classmethod
def from_boundary(cls, value: object) -> SecretKeyName:
"""Coerce a boundary value into a secret key name."""
return cls(_required_string(value, "secret key name"))


@dataclass(frozen=True, slots=True)
class IpNetworkCidr:
"""Validated canonical IPv4 or IPv6 network in CIDR notation."""

value: str

def __post_init__(self) -> None:
"""Enforce a canonical network with no host bits set."""
parsed = ip_network(self.value, strict=True)
if str(parsed) != self.value:
message = f"IP network is not canonical: {self.value!r}"
raise ValueError(message)

@property
def is_unrestricted(self) -> bool:
"""Return whether the network matches every address of its family."""
return ip_network(self.value).prefixlen == 0

@classmethod
def from_boundary(cls, value: object) -> IpNetworkCidr:
"""Coerce a boundary value into an IP network."""
return cls(_required_string(value, "IP network"))


@dataclass(frozen=True, slots=True)
class ReleaseId:
"""Validated component release identifier."""
Expand Down
90 changes: 88 additions & 2 deletions sdk/src/cloudfall/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
HttpScheme,
HttpStatusCode,
IpAddress,
IpNetworkCidr,
Ipv4Address,
Ipv6NetworkCidr,
LinuxUser,
Expand All @@ -40,6 +41,7 @@
PositiveCount,
PostgresDatabaseName,
PostgresMajorVersion,
PostgresRoleName,
PromqlExpression,
ProviderServerId,
RaidLevel,
Expand All @@ -52,6 +54,7 @@
RuntimePackageManager,
RuntimeType,
RuntimeVersion,
SecretKeyName,
SecretScope,
ServerLifecycle,
ServiceCommand,
Expand Down Expand Up @@ -637,13 +640,63 @@ def as_dict(self) -> dict[str, object]:
}


@dataclass(frozen=True, slots=True)
class SecretValueReference:
"""Pointer to one key of one secret source; never carries the value."""

environment: ResourceId
path: AbsolutePath
key: SecretKeyName

def as_dict(self) -> dict[str, object]:
"""Serialize the reference itself (references are not secret)."""
return {
"environment": self.environment.value,
"path": self.path.value,
"key": self.key.value,
}


@dataclass(frozen=True, slots=True)
class OwnedPostgresDatabase:
"""One PostgreSQL database owned by a declared role, not an application."""

name: PostgresDatabaseName
owner: PostgresRoleName
password_ref: SecretValueReference

def as_dict(self) -> dict[str, object]:
"""Serialize the database with its role and password reference."""
return {
"name": self.name.value,
"owner": self.owner.value,
"passwordRef": self.password_ref.as_dict(),
}


@dataclass(frozen=True, slots=True)
class PostgresRemoteAccess:
"""Listener and client networks for outside apps reaching owned roles."""

listen_address: IpAddress
allowed_sources: tuple[IpNetworkCidr, ...]

def as_dict(self) -> dict[str, object]:
"""Serialize the remote access declaration."""
return {
"listenAddress": self.listen_address.value,
"allowedSources": [source.value for source in self.allowed_sources],
}


@dataclass(frozen=True, slots=True)
class PostgresqlService:
"""PostgreSQL-specific service contract."""

major_version: PostgresMajorVersion
package_version: PackageVersion | None
databases: tuple[PostgresDatabase, ...]
databases: tuple[PostgresDatabase | OwnedPostgresDatabase, ...]
remote_access: PostgresRemoteAccess | None = None


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -725,6 +778,10 @@ def as_dict(self) -> dict[str, object]:
postgresql["packageVersion"] = (
self.postgresql.package_version.value
)
if self.postgresql.remote_access is not None:
postgresql["remoteAccess"] = (
self.postgresql.remote_access.as_dict()
)
result["postgresql"] = postgresql
if self.redis is not None:
redis: dict[str, object] = {
Expand Down Expand Up @@ -1615,6 +1672,11 @@ def _service_inventory(
raw_postgresql.get("databases"), "service databases"
)
),
remote_access=(
_postgres_remote_access(_mapping(raw_postgresql, "remoteAccess"))
if "remoteAccess" in raw_postgresql
else None
),
)
redis = None
if service_kind is ServiceKind.REDIS:
Expand Down Expand Up @@ -1727,7 +1789,18 @@ def _service_metrics_enabled(
def _postgres_database(
content: Mapping[str, object],
applications_by_id: Mapping[ResourceId, ApplicationInventory],
) -> PostgresDatabase:
) -> PostgresDatabase | OwnedPostgresDatabase:
if "owner" in content:
password_ref = _mapping(content, "passwordRef")
return OwnedPostgresDatabase(
name=PostgresDatabaseName.from_boundary(content.get("name")),
owner=PostgresRoleName.from_boundary(content.get("owner")),
password_ref=SecretValueReference(
environment=ResourceId.from_boundary(password_ref.get("environment")),
path=AbsolutePath.from_boundary(password_ref.get("path")),
key=SecretKeyName.from_boundary(password_ref.get("key")),
),
)
application_id = ResourceId.from_boundary(content.get("application"))
application = applications_by_id.get(application_id)
if application is None:
Expand All @@ -1742,6 +1815,19 @@ def _postgres_database(
)


def _postgres_remote_access(content: Mapping[str, object]) -> PostgresRemoteAccess:
raw_sources = content.get("allowedSources")
if not isinstance(raw_sources, list) or not raw_sources:
message = "validated remote access sources are not a non-empty list"
raise ValueError(message)
return PostgresRemoteAccess(
listen_address=IpAddress.from_boundary(content.get("listenAddress")),
allowed_sources=tuple(
IpNetworkCidr.from_boundary(source) for source in raw_sources
),
)


def _ssh_public_key_inventory(
document: ResourceDocument,
) -> SshPublicKeyInventory:
Expand Down
Loading
Loading