Compare commits
3
Commits
0301cbff6f
...
9da68a9625
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9da68a9625 | ||
|
|
3918a2ac71 | ||
|
|
356e3beccd |
No files matched your search
@@ -7,10 +7,10 @@ organised in private **vaults** (each vault is the web equivalent of one XCA
|
|||||||
database).
|
database).
|
||||||
|
|
||||||
- **`server/`** — Python + [kaya](https://github.com/woggioni/kaya) backend:
|
- **`server/`** — Python + [kaya](https://github.com/woggioni/kaya) backend:
|
||||||
OIDC login, Redis sessions, personal API tokens, Postgres (Tortoise ORM +
|
OIDC login, Redis sessions, personal API tokens, PostgreSQL or SQLite
|
||||||
aerich migrations), AES-256-GCM private-key encryption, and a complete REST
|
(Tortoise ORM + aerich migrations), AES-256-GCM private-key encryption, and
|
||||||
API. The pure PKI domain logic lives in `packages/kca-core`; the deployable
|
a complete REST API. The pure PKI domain logic lives in `packages/kca-core`;
|
||||||
app is `packages/kca-app`.
|
the deployable app is `packages/kca-app`.
|
||||||
- **`web/`** — Rust + [Sycamore](https://sycamore.dev) compiled to WebAssembly
|
- **`web/`** — Rust + [Sycamore](https://sycamore.dev) compiled to WebAssembly
|
||||||
by [Trunk](https://trunkrs.dev), styled with Bootstrap 5.3 (vendored under
|
by [Trunk](https://trunkrs.dev), styled with Bootstrap 5.3 (vendored under
|
||||||
`web/vendor/`) and a dark PKI-console theme.
|
`web/vendor/`) and a dark PKI-console theme.
|
||||||
@@ -50,6 +50,45 @@ OIDC_0_REDIRECT_URI=https://kca.example.com/auth/default/callback
|
|||||||
|
|
||||||
`server/.env.example` documents every setting.
|
`server/.env.example` documents every setting.
|
||||||
|
|
||||||
|
## Database backends
|
||||||
|
|
||||||
|
PostgreSQL is the default production backend. KCA also supports persistent,
|
||||||
|
file-backed SQLite for single-writer deployments. Configure it from `server/`
|
||||||
|
with either the component settings:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export DATABASE_ENGINE=sqlite
|
||||||
|
export DATABASE_NAME=/data/kca.db
|
||||||
|
export DATABASE_OPTIONS=busy_timeout=5000
|
||||||
|
```
|
||||||
|
|
||||||
|
or one URL:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export DATABASE_URL='sqlite:///data/kca.db?busy_timeout=5000'
|
||||||
|
```
|
||||||
|
|
||||||
|
Run migrations before starting or upgrading the app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run aerich upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
Tortoise enables WAL journaling and foreign-key enforcement by default; the
|
||||||
|
busy timeout allows concurrent readers and a writer to wait briefly for locks.
|
||||||
|
Only in-memory SQLite test databases create their schemas implicitly.
|
||||||
|
|
||||||
|
For containers, mount a persistent volume at `/data` for both the migration
|
||||||
|
and application containers and use the same SQLite URL for each. The image's
|
||||||
|
`/data` directory is writable by its non-root application user. When invoking
|
||||||
|
`aerich upgrade` directly in the image, set its working directory to `/app`
|
||||||
|
(for example, `docker run -w /app ...`). Keep SQLite to one application
|
||||||
|
writer/replica, and use SQLite-aware backups rather than independently copying
|
||||||
|
live database, WAL and shared-memory files.
|
||||||
|
|
||||||
|
When changing models, migration SQL must support both PostgreSQL and SQLite.
|
||||||
|
The PostgreSQL migration commands below remain the default development flow.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
### Vaults
|
### Vaults
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Certificate key selection mirrors XCA's unused-key filtering.
|
||||||
|
const { test, expect } = require("@playwright/test");
|
||||||
|
|
||||||
|
function unique(prefix) {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function field(page, label) {
|
||||||
|
return page.locator("label").filter({ hasText: label }).first().locator("..").locator("input, select, textarea").first();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(page, username) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByRole("link", { name: "Sign in with default" }).click();
|
||||||
|
await page.getByPlaceholder("Enter any user/subject").fill(username);
|
||||||
|
await page.getByRole("button", { name: "Sign-in" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Vaults" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
function existingKeySelect(page) {
|
||||||
|
return page.locator("label").filter({ hasText: "Existing key" }).locator("xpath=following-sibling::select[1]");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("certificate key selection hides used keys unless requested", async ({ page }) => {
|
||||||
|
const user = unique("key-selection");
|
||||||
|
const vaultName = unique("Key Selection Vault");
|
||||||
|
const usedKeyName = unique("used-key");
|
||||||
|
const unusedKeyName = unique("unused-key");
|
||||||
|
const certName = unique("key-selection.example");
|
||||||
|
|
||||||
|
await login(page, user);
|
||||||
|
await page.getByRole("button", { name: "New vault" }).click();
|
||||||
|
await page.getByPlaceholder("Production PKI").fill(vaultName);
|
||||||
|
await page.getByRole("button", { name: "Create vault" }).click();
|
||||||
|
await page.getByRole("link", { name: vaultName }).click();
|
||||||
|
const vaultUrl = page.url();
|
||||||
|
|
||||||
|
for (const keyName of [usedKeyName, unusedKeyName]) {
|
||||||
|
await page.goto(`${vaultUrl}/keys/new`);
|
||||||
|
await (await field(page, "Internal name")).fill(keyName);
|
||||||
|
await page.getByRole("button", { name: "Generate key" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: keyName })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use one key in a certificate so subsequent forms can distinguish it.
|
||||||
|
await page.goto(`${vaultUrl}/certificates/new`);
|
||||||
|
let keySelect = existingKeySelect(page);
|
||||||
|
const usedKeyOption = keySelect.locator("option", { hasText: usedKeyName });
|
||||||
|
await expect(usedKeyOption).toHaveCount(1);
|
||||||
|
const usedKeyId = await usedKeyOption.getAttribute("value");
|
||||||
|
expect(usedKeyId).toBeTruthy();
|
||||||
|
await keySelect.selectOption(usedKeyId);
|
||||||
|
await (await field(page, "Internal name")).fill(certName);
|
||||||
|
await (await field(page, "Common name (CN)")).fill(certName);
|
||||||
|
await page.getByRole("button", { name: "Create certificate" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: certName })).toBeVisible();
|
||||||
|
const certUrl = page.url();
|
||||||
|
|
||||||
|
// Copying a certificate includes used keys and retains the original key.
|
||||||
|
await page.goto(`${certUrl}/similar`);
|
||||||
|
keySelect = existingKeySelect(page);
|
||||||
|
let includeUsed = page.getByLabel("Include used keys", { exact: true });
|
||||||
|
await expect(includeUsed).toBeChecked();
|
||||||
|
await expect(keySelect).toHaveValue(usedKeyId);
|
||||||
|
await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(1);
|
||||||
|
|
||||||
|
// New certificates show only unused private keys by default.
|
||||||
|
await page.goto(`${vaultUrl}/certificates/new`);
|
||||||
|
keySelect = existingKeySelect(page);
|
||||||
|
includeUsed = page.getByLabel("Include used keys", { exact: true });
|
||||||
|
await expect(includeUsed).not.toBeChecked();
|
||||||
|
await expect(keySelect.locator("option", { hasText: unusedKeyName })).toHaveCount(1);
|
||||||
|
await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(0);
|
||||||
|
|
||||||
|
await includeUsed.check();
|
||||||
|
await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(1);
|
||||||
|
await expect(keySelect.locator("option", { hasText: unusedKeyName })).toHaveCount(1);
|
||||||
|
|
||||||
|
// Hiding a selected used key also clears the form value.
|
||||||
|
await keySelect.selectOption(usedKeyId);
|
||||||
|
await includeUsed.uncheck();
|
||||||
|
await expect(keySelect).toHaveValue("");
|
||||||
|
await expect(keySelect.locator("option", { hasText: usedKeyName })).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# Environment configuration for KCA. Copy to .env or export the variables.
|
# Environment configuration for KCA. Copy to .env or export the variables.
|
||||||
|
|
||||||
# --- Database --------------------------------------------------------------
|
# --- Database --------------------------------------------------------------
|
||||||
|
# PostgreSQL (default). Run `aerich upgrade` before starting the app.
|
||||||
DATABASE_ENGINE=postgres
|
DATABASE_ENGINE=postgres
|
||||||
DATABASE_HOST=localhost
|
DATABASE_HOST=localhost
|
||||||
DATABASE_PORT=5432
|
DATABASE_PORT=5432
|
||||||
@@ -10,6 +11,15 @@ DATABASE_PASSWORD=password
|
|||||||
#DATABASE_OPTIONS=
|
#DATABASE_OPTIONS=
|
||||||
#DATABASE_URL=
|
#DATABASE_URL=
|
||||||
|
|
||||||
|
# SQLite production alternative: DATABASE_NAME is an absolute or relative file
|
||||||
|
# path; host, port, user and password are ignored. Use a persistent writable
|
||||||
|
# filesystem and run `aerich upgrade` before starting the app.
|
||||||
|
#DATABASE_ENGINE=sqlite
|
||||||
|
#DATABASE_NAME=/data/kca.db
|
||||||
|
#DATABASE_OPTIONS=busy_timeout=5000
|
||||||
|
# Equivalent single-setting form:
|
||||||
|
#DATABASE_URL=sqlite:///data/kca.db?busy_timeout=5000
|
||||||
|
|
||||||
# --- OIDC ------------------------------------------------------------------
|
# --- OIDC ------------------------------------------------------------------
|
||||||
# Indexed blocks OIDC_0_*, OIDC_1_*, ... with contiguous indices from 0.
|
# Indexed blocks OIDC_0_*, OIDC_1_*, ... with contiguous indices from 0.
|
||||||
OIDC_0_PROVIDER=default
|
OIDC_0_PROVIDER=default
|
||||||
|
|||||||
+2
-1
@@ -67,7 +67,8 @@ RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' \
|
|||||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata libssl3 \
|
&& apt-get install -y --no-install-recommends ca-certificates tzdata libssl3 \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& groupadd --system app \
|
&& groupadd --system app \
|
||||||
&& useradd --system --gid app --no-create-home --shell /usr/sbin/nologin app
|
&& useradd --system --gid app --no-create-home --shell /usr/sbin/nologin app \
|
||||||
|
&& install -d -o app -g app /data
|
||||||
|
|
||||||
COPY --from=builder /opt/venv /opt/venv
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
COPY --from=builder /repo/server/migrations /app/migrations
|
COPY --from=builder /repo/server/migrations /app/migrations
|
||||||
|
|||||||
@@ -3,7 +3,223 @@ from tortoise import BaseDBAsyncClient
|
|||||||
RUN_IN_TRANSACTION = True
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
# SQLite production support was introduced after migration 4. Bootstrap new
|
||||||
|
# SQLite databases directly at that current schema; migrations 1-4 are no-ops
|
||||||
|
# on SQLite. PostgreSQL retains the historical migration path below.
|
||||||
|
SQLITE_UPGRADE = """
|
||||||
|
CREATE TABLE IF NOT EXISTS "api_token" (
|
||||||
|
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
"user_issuer" VARCHAR(2048) NOT NULL,
|
||||||
|
"user_sub" VARCHAR(255) NOT NULL,
|
||||||
|
"name" VARCHAR(100) NOT NULL,
|
||||||
|
"token_prefix" VARCHAR(16) NOT NULL,
|
||||||
|
"token_hash" VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
"created_at" TIMESTAMP NOT NULL,
|
||||||
|
"last_used_at" TIMESTAMP,
|
||||||
|
"expires_at" DATE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_api_token_user_is_612f0d" ON "api_token" ("user_issuer", "user_sub");
|
||||||
|
CREATE TABLE IF NOT EXISTS "ocsp_snapshot" (
|
||||||
|
"ca_id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
"dirty" INT NOT NULL,
|
||||||
|
"published_at" TIMESTAMP,
|
||||||
|
"last_error" TEXT,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_ocsp_snapsh_dirty_a8d440" ON "ocsp_snapshot" ("dirty");
|
||||||
|
CREATE TABLE IF NOT EXISTS "app_user" (
|
||||||
|
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
"issuer" VARCHAR(2048) NOT NULL,
|
||||||
|
"sub" VARCHAR(255) NOT NULL,
|
||||||
|
"display_name" VARCHAR(200) NOT NULL,
|
||||||
|
"name" VARCHAR(255),
|
||||||
|
"family_name" VARCHAR(255),
|
||||||
|
"given_name" VARCHAR(255),
|
||||||
|
"middle_name" VARCHAR(255),
|
||||||
|
"nickname" VARCHAR(255),
|
||||||
|
"preferred_username" VARCHAR(255),
|
||||||
|
"profile" VARCHAR(2048),
|
||||||
|
"picture" VARCHAR(2048),
|
||||||
|
"website" VARCHAR(2048),
|
||||||
|
"gender" VARCHAR(100),
|
||||||
|
"birthdate" VARCHAR(32),
|
||||||
|
"zoneinfo" VARCHAR(100),
|
||||||
|
"locale" VARCHAR(100),
|
||||||
|
"updated_at" BIGINT,
|
||||||
|
"created_at" TIMESTAMP NOT NULL,
|
||||||
|
"last_seen_at" TIMESTAMP NOT NULL,
|
||||||
|
CONSTRAINT "uid_app_user_issuer_d243fe" UNIQUE ("issuer", "sub")
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS "vault" (
|
||||||
|
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
"owner_issuer" VARCHAR(2048) NOT NULL,
|
||||||
|
"owner_sub" VARCHAR(255) NOT NULL,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"settings" JSON NOT NULL,
|
||||||
|
"version" INT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_vault_owner_i_a035fb" ON "vault" ("owner_issuer", "owner_sub");
|
||||||
|
CREATE TABLE IF NOT EXISTS "key" (
|
||||||
|
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"comment" TEXT NOT NULL,
|
||||||
|
"source" VARCHAR(20) NOT NULL,
|
||||||
|
"key_type" VARCHAR(20) NOT NULL,
|
||||||
|
"key_size" INT,
|
||||||
|
"curve" VARCHAR(100),
|
||||||
|
"public_der" BLOB NOT NULL,
|
||||||
|
"public_key_sha256" VARCHAR(64) NOT NULL,
|
||||||
|
"private_der" BLOB,
|
||||||
|
"private_nonce" BLOB,
|
||||||
|
"has_private" INT NOT NULL,
|
||||||
|
"version" INT NOT NULL,
|
||||||
|
"search_text" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL,
|
||||||
|
"vault_id" CHAR(36) NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_key_vault_i_e0ffb5" UNIQUE ("vault_id", "public_key_sha256")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_key_public__eba47e" ON "key" ("public_key_sha256");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_key_vault_i_a6f27f" ON "key" ("vault_id", "name");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_key_vault_i_c2e997" ON "key" ("vault_id", "key_type");
|
||||||
|
CREATE TABLE IF NOT EXISTS "request" (
|
||||||
|
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"comment" TEXT NOT NULL,
|
||||||
|
"source" VARCHAR(20) NOT NULL,
|
||||||
|
"subject_der" BLOB NOT NULL,
|
||||||
|
"req_der" BLOB NOT NULL,
|
||||||
|
"public_key_der" BLOB NOT NULL,
|
||||||
|
"request_sha256" VARCHAR(64) NOT NULL,
|
||||||
|
"signed" INT NOT NULL,
|
||||||
|
"signed_at" TIMESTAMP,
|
||||||
|
"attributes" JSON NOT NULL,
|
||||||
|
"version" INT NOT NULL,
|
||||||
|
"search_text" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL,
|
||||||
|
"key_id" CHAR(36) REFERENCES "key" ("id") ON DELETE SET NULL,
|
||||||
|
"vault_id" CHAR(36) NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_request_vault_i_b661f4" UNIQUE ("vault_id", "request_sha256")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_request_request_fad061" ON "request" ("request_sha256");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_request_vault_i_346e22" ON "request" ("vault_id", "name");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_request_vault_i_2cd9ef" ON "request" ("vault_id", "signed");
|
||||||
|
CREATE TABLE IF NOT EXISTS "certificate" (
|
||||||
|
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"comment" TEXT NOT NULL,
|
||||||
|
"source" VARCHAR(20) NOT NULL,
|
||||||
|
"serial_hex" VARCHAR(128) NOT NULL,
|
||||||
|
"subject_der" BLOB NOT NULL,
|
||||||
|
"issuer_der" BLOB NOT NULL,
|
||||||
|
"subject_hash" VARCHAR(64) NOT NULL,
|
||||||
|
"issuer_hash" VARCHAR(64) NOT NULL,
|
||||||
|
"not_before" TIMESTAMP NOT NULL,
|
||||||
|
"not_after" TIMESTAMP NOT NULL,
|
||||||
|
"no_expiration" INT NOT NULL,
|
||||||
|
"is_ca" INT NOT NULL,
|
||||||
|
"path_length" INT,
|
||||||
|
"cert_der" BLOB NOT NULL,
|
||||||
|
"public_key_der" BLOB NOT NULL,
|
||||||
|
"signature_algorithm" VARCHAR(100) NOT NULL,
|
||||||
|
"fingerprint_md5" VARCHAR(32) NOT NULL,
|
||||||
|
"fingerprint_sha1" VARCHAR(40) NOT NULL,
|
||||||
|
"fingerprint_sha256" VARCHAR(64) NOT NULL,
|
||||||
|
"version" INT NOT NULL,
|
||||||
|
"search_text" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL,
|
||||||
|
"issuer_id" CHAR(36) REFERENCES "certificate" ("id") ON DELETE SET NULL,
|
||||||
|
"key_id" CHAR(36) REFERENCES "key" ("id") ON DELETE SET NULL,
|
||||||
|
"request_id" CHAR(36) REFERENCES "request" ("id") ON DELETE SET NULL,
|
||||||
|
"vault_id" CHAR(36) NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_certificate_vault_i_842384" UNIQUE ("vault_id", "fingerprint_sha256")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_certificate_serial__787fd1" ON "certificate" ("serial_hex");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_certificate_subject_ed411b" ON "certificate" ("subject_hash");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_certificate_issuer__b8daa0" ON "certificate" ("issuer_hash");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_certificate_is_ca_906cf3" ON "certificate" ("is_ca");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_certificate_fingerp_12be2c" ON "certificate" ("fingerprint_sha256");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_certificate_vault_i_18b9a0" ON "certificate" ("vault_id", "name");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_certificate_vault_i_dfb18f" ON "certificate" ("vault_id", "issuer_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_certificate_vault_i_eded8f" ON "certificate" ("vault_id", "not_after");
|
||||||
|
CREATE TABLE IF NOT EXISTS "certificate_revocation" (
|
||||||
|
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
"serial_hex" VARCHAR(128) NOT NULL,
|
||||||
|
"revoked_at" TIMESTAMP NOT NULL,
|
||||||
|
"invalidity_date" TIMESTAMP,
|
||||||
|
"reason" VARCHAR(50) NOT NULL,
|
||||||
|
"crl_number" INT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP NOT NULL,
|
||||||
|
"ca_id" CHAR(36) NOT NULL REFERENCES "certificate" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_certificate_ca_id_1c9aa5" UNIQUE ("ca_id", "serial_hex")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_certificate_serial__8fa20f" ON "certificate_revocation" ("serial_hex");
|
||||||
|
CREATE TABLE IF NOT EXISTS "crl" (
|
||||||
|
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"comment" TEXT NOT NULL,
|
||||||
|
"source" VARCHAR(20) NOT NULL,
|
||||||
|
"crl_der" BLOB NOT NULL,
|
||||||
|
"crl_hash" VARCHAR(64) NOT NULL,
|
||||||
|
"crl_number" INT,
|
||||||
|
"last_update" TIMESTAMP NOT NULL,
|
||||||
|
"next_update" TIMESTAMP,
|
||||||
|
"revoked_count" INT NOT NULL,
|
||||||
|
"signature_algorithm" VARCHAR(100) NOT NULL,
|
||||||
|
"search_text" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL,
|
||||||
|
"issuer_id" CHAR(36) REFERENCES "certificate" ("id") ON DELETE SET NULL,
|
||||||
|
"vault_id" CHAR(36) NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_crl_vault_i_e5461a" UNIQUE ("vault_id", "crl_hash")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_crl_crl_has_c44d61" ON "crl" ("crl_hash");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_crl_vault_i_6af15c" ON "crl" ("vault_id", "name");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_crl_vault_i_576344" ON "crl" ("vault_id", "issuer_id");
|
||||||
|
CREATE TABLE IF NOT EXISTS "template" (
|
||||||
|
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
"name" VARCHAR(200) NOT NULL,
|
||||||
|
"comment" TEXT NOT NULL,
|
||||||
|
"source" VARCHAR(20) NOT NULL,
|
||||||
|
"subject" JSON NOT NULL,
|
||||||
|
"settings" JSON NOT NULL,
|
||||||
|
"version" INT NOT NULL,
|
||||||
|
"search_text" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL,
|
||||||
|
"vault_id" CHAR(36) NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_template_vault_i_18d8f5" ON "template" ("vault_id", "name");
|
||||||
|
CREATE TABLE IF NOT EXISTS "authority" (
|
||||||
|
"id" CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
"template_key" VARCHAR(64),
|
||||||
|
"crl_expire" TIMESTAMP,
|
||||||
|
"crl_number" INT NOT NULL,
|
||||||
|
"crl_days" INT NOT NULL,
|
||||||
|
"tls_auth_key" BLOB,
|
||||||
|
"tls_auth_key_nonce" BLOB,
|
||||||
|
"created_at" TIMESTAMP NOT NULL,
|
||||||
|
"updated_at" TIMESTAMP NOT NULL,
|
||||||
|
"template_id" CHAR(36) REFERENCES "template" ("id") ON DELETE SET NULL,
|
||||||
|
"certificate_id" CHAR(36) NOT NULL UNIQUE REFERENCES "certificate" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS "aerich" (
|
||||||
|
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
"version" VARCHAR(255) NOT NULL,
|
||||||
|
"app" VARCHAR(100) NOT NULL,
|
||||||
|
"content" JSON NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
if db.capabilities.dialect == "sqlite":
|
||||||
|
return SQLITE_UPGRADE
|
||||||
return """
|
return """
|
||||||
CREATE TABLE IF NOT EXISTS "api_token" (
|
CREATE TABLE IF NOT EXISTS "api_token" (
|
||||||
"id" UUID NOT NULL PRIMARY KEY,
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ RUN_IN_TRANSACTION = True
|
|||||||
|
|
||||||
|
|
||||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
# Migration 0 bootstraps SQLite at the current schema.
|
||||||
|
if db.capabilities.dialect == "sqlite":
|
||||||
|
return "SELECT 1;"
|
||||||
return """
|
return """
|
||||||
ALTER TABLE "certificate" ADD "no_expiration" BOOL NOT NULL DEFAULT False;"""
|
ALTER TABLE "certificate" ADD "no_expiration" BOOL NOT NULL DEFAULT False;"""
|
||||||
|
|
||||||
|
|
||||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
if db.capabilities.dialect == "sqlite":
|
||||||
|
return "SELECT 1;"
|
||||||
return """
|
return """
|
||||||
ALTER TABLE "certificate" DROP COLUMN "no_expiration";"""
|
ALTER TABLE "certificate" DROP COLUMN "no_expiration";"""
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ RUN_IN_TRANSACTION = True
|
|||||||
|
|
||||||
|
|
||||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
# Migration 0 bootstraps SQLite at the current schema.
|
||||||
|
if db.capabilities.dialect == "sqlite":
|
||||||
|
return "SELECT 1;"
|
||||||
return """
|
return """
|
||||||
ALTER TABLE "authority" ADD "template_key" VARCHAR(64);"""
|
ALTER TABLE "authority" ADD "template_key" VARCHAR(64);"""
|
||||||
|
|
||||||
|
|
||||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
if db.capabilities.dialect == "sqlite":
|
||||||
|
return "SELECT 1;"
|
||||||
return """
|
return """
|
||||||
ALTER TABLE "authority" DROP COLUMN "template_key";"""
|
ALTER TABLE "authority" DROP COLUMN "template_key";"""
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ RUN_IN_TRANSACTION = True
|
|||||||
|
|
||||||
|
|
||||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
# Migration 0 bootstraps SQLite at the current schema.
|
||||||
|
if db.capabilities.dialect == "sqlite":
|
||||||
|
return "SELECT 1;"
|
||||||
return """
|
return """
|
||||||
CREATE TABLE IF NOT EXISTS "ocsp_snapshot" (
|
CREATE TABLE IF NOT EXISTS "ocsp_snapshot" (
|
||||||
"ca_id" UUID NOT NULL PRIMARY KEY,
|
"ca_id" UUID NOT NULL PRIMARY KEY,
|
||||||
@@ -17,6 +20,8 @@ COMMENT ON TABLE "ocsp_snapshot" IS 'Publication state for one CA''s OCSP status
|
|||||||
|
|
||||||
|
|
||||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
if db.capabilities.dialect == "sqlite":
|
||||||
|
return "SELECT 1;"
|
||||||
return """
|
return """
|
||||||
DROP TABLE IF EXISTS "ocsp_snapshot";"""
|
DROP TABLE IF EXISTS "ocsp_snapshot";"""
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ RUN_IN_TRANSACTION = True
|
|||||||
|
|
||||||
|
|
||||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
# Migration 0 bootstraps SQLite at the current schema.
|
||||||
|
if db.capabilities.dialect == "sqlite":
|
||||||
|
return "SELECT 1;"
|
||||||
return """
|
return """
|
||||||
DROP INDEX IF EXISTS "idx_vault_owner_s_cdc1f8";
|
DROP INDEX IF EXISTS "idx_vault_owner_s_cdc1f8";
|
||||||
ALTER TABLE "app_user" DROP CONSTRAINT IF EXISTS "app_user_sub_key";
|
ALTER TABLE "app_user" DROP CONSTRAINT IF EXISTS "app_user_sub_key";
|
||||||
@@ -21,6 +24,8 @@ async def upgrade(db: BaseDBAsyncClient) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
if db.capabilities.dialect == "sqlite":
|
||||||
|
return "SELECT 1;"
|
||||||
return """
|
return """
|
||||||
DROP INDEX IF EXISTS "idx_vault_owner_i_a035fb";
|
DROP INDEX IF EXISTS "idx_vault_owner_i_a035fb";
|
||||||
DROP INDEX IF EXISTS "uid_app_user_issuer_d243fe";
|
DROP INDEX IF EXISTS "uid_app_user_issuer_d243fe";
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ dependencies = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
s3 = [
|
s3 = [
|
||||||
"boto3>=1.34",
|
"aioboto3>=13.0",
|
||||||
]
|
]
|
||||||
otel = [
|
otel = [
|
||||||
"kaya-otel>=0.0.7",
|
"kaya-otel>=0.0.7",
|
||||||
|
|||||||
@@ -142,13 +142,18 @@ def _database_url_from_parts(
|
|||||||
name: str,
|
name: str,
|
||||||
options: str,
|
options: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
netloc = quote(user, safe="")
|
if engine == "sqlite":
|
||||||
if password:
|
# DATABASE_NAME is a file path for SQLite (or :memory: for tests), so
|
||||||
netloc += ":" + quote(password, safe="")
|
# network credentials and host/port settings do not apply.
|
||||||
netloc += "@" + host
|
url = f"sqlite://{name}"
|
||||||
if port:
|
else:
|
||||||
netloc += ":" + port
|
netloc = quote(user, safe="")
|
||||||
url = f"{engine}://{netloc}/{name}"
|
if password:
|
||||||
|
netloc += ":" + quote(password, safe="")
|
||||||
|
netloc += "@" + host
|
||||||
|
if port:
|
||||||
|
netloc += ":" + port
|
||||||
|
url = f"{engine}://{netloc}/{name}"
|
||||||
options = options.lstrip("?")
|
options = options.lstrip("?")
|
||||||
if options:
|
if options:
|
||||||
url += "?" + options
|
url += "?" + options
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ def cert_subject(ca: Certificate) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _s3_client() -> Any:
|
def _s3_client() -> Any:
|
||||||
import boto3
|
import aioboto3
|
||||||
from botocore.config import Config
|
from botocore.config import Config
|
||||||
|
|
||||||
kwargs: Dict[str, Any] = {
|
kwargs: Dict[str, Any] = {
|
||||||
@@ -94,10 +94,9 @@ def _s3_client() -> Any:
|
|||||||
}
|
}
|
||||||
if settings.ocsp_s3_force_path_style:
|
if settings.ocsp_s3_force_path_style:
|
||||||
kwargs["config"] = Config(s3={"addressing_style": "path"})
|
kwargs["config"] = Config(s3={"addressing_style": "path"})
|
||||||
# boto3's create_client rejects an explicit None region/endpoint in some
|
# Only pass region and endpoint values that are explicitly configured.
|
||||||
# versions, so only pass values that are set.
|
|
||||||
kwargs = {key: value for key, value in kwargs.items() if value is not None}
|
kwargs = {key: value for key, value in kwargs.items() if value is not None}
|
||||||
return boto3.client("s3", **kwargs)
|
return aioboto3.Session().client("s3", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] = None) -> str:
|
async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] = None) -> str:
|
||||||
@@ -109,11 +108,8 @@ async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] =
|
|||||||
body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8")
|
body = json.dumps(snapshot, indent=2, sort_keys=True).encode("utf-8")
|
||||||
key = object_key(str(ca.id))
|
key = object_key(str(ca.id))
|
||||||
|
|
||||||
# boto3 is synchronous and credential resolution can block; do both the
|
async with _s3_client() as client:
|
||||||
# client creation and the PUT outside the event loop.
|
await client.put_object(
|
||||||
def upload() -> None:
|
|
||||||
client = _s3_client()
|
|
||||||
client.put_object(
|
|
||||||
Bucket=settings.ocsp_s3_bucket,
|
Bucket=settings.ocsp_s3_bucket,
|
||||||
Key=key,
|
Key=key,
|
||||||
Body=body,
|
Body=body,
|
||||||
@@ -121,7 +117,6 @@ async def publish_snapshot(ca: Certificate, snapshot: Optional[Dict[str, Any]] =
|
|||||||
CacheControl="no-store",
|
CacheControl="no-store",
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.to_thread(upload)
|
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
@@ -171,10 +166,10 @@ class OcspPublisherMixin(KayaMixin):
|
|||||||
log.info("OCSP S3 publication disabled (KCA_OCSP_S3_BUCKET unset)")
|
log.info("OCSP S3 publication disabled (KCA_OCSP_S3_BUCKET unset)")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
import boto3 # noqa: F401
|
import aioboto3 # noqa: F401
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"KCA_OCSP_S3_BUCKET is set but boto3 is not installed; "
|
"KCA_OCSP_S3_BUCKET is set but aioboto3 is not installed; "
|
||||||
"install kca-app with the 's3' extra"
|
"install kca-app with the 's3' extra"
|
||||||
) from exc
|
) from exc
|
||||||
self._task = loop.create_task(self._poll())
|
self._task = loop.create_task(self._poll())
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ It deliberately avoids the global-fallback singleton
|
|||||||
per process and would therefore break across event loops.
|
per process and would therefore break across event loops.
|
||||||
|
|
||||||
Schema management is split by backend: in-memory sqlite databases (the
|
Schema management is split by backend: in-memory sqlite databases (the
|
||||||
test suite) get ``generate_schemas`` on every fresh context; Postgres
|
test suite) get ``generate_schemas`` on every fresh context; Postgres and
|
||||||
schemas are owned by aerich migrations and must be applied externally
|
file-backed SQLite schemas are owned by aerich migrations and must be
|
||||||
(``aerich upgrade``, run by the ``db-migrate`` compose service) before
|
applied externally (``aerich upgrade``, run by the ``db-migrate`` compose
|
||||||
the app serves requests.
|
service) before the app serves requests.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -104,12 +104,17 @@ class TortoiseMixin(KayaMixin):
|
|||||||
modules={"models": self._models_modules},
|
modules={"models": self._models_modules},
|
||||||
)
|
)
|
||||||
log.info("database context initialized (%s)", _safe_url(self._database_url))
|
log.info("database context initialized (%s)", _safe_url(self._database_url))
|
||||||
# Schema creation is only done for sqlite (in-memory test
|
# Only ephemeral test databases are created implicitly. Postgres
|
||||||
# databases). Postgres schemas are managed by aerich migrations
|
# and persistent SQLite schemas are managed by aerich migrations
|
||||||
# (applied by the db-migrate compose service / `aerich upgrade`).
|
# (applied by the db-migrate compose service / `aerich upgrade`).
|
||||||
if self._database_url.startswith("sqlite"):
|
connection = ctx.connections.get("default")
|
||||||
|
is_memory_sqlite = (
|
||||||
|
connection.capabilities.dialect == "sqlite"
|
||||||
|
and getattr(connection, "filename", None) == ":memory:"
|
||||||
|
)
|
||||||
|
if is_memory_sqlite:
|
||||||
await ctx.generate_schemas()
|
await ctx.generate_schemas()
|
||||||
log.info("sqlite schemas generated")
|
log.info("in-memory sqlite schemas generated")
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
async def ensure_context(self) -> None:
|
async def ensure_context(self) -> None:
|
||||||
|
|||||||
@@ -1222,10 +1222,16 @@ class OcspSnapshotTests(unittest.TestCase):
|
|||||||
from kca.models import OcspSnapshot
|
from kca.models import OcspSnapshot
|
||||||
|
|
||||||
class FakeS3:
|
class FakeS3:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, traceback):
|
||||||
|
return None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|
||||||
def put_object(self, **kwargs):
|
async def put_object(self, **kwargs):
|
||||||
self.calls.append(kwargs)
|
self.calls.append(kwargs)
|
||||||
|
|
||||||
fake = FakeS3()
|
fake = FakeS3()
|
||||||
@@ -1342,10 +1348,16 @@ class OcspSnapshotTests(unittest.TestCase):
|
|||||||
from kca.models import Certificate, OcspSnapshot
|
from kca.models import Certificate, OcspSnapshot
|
||||||
|
|
||||||
class FakeS3:
|
class FakeS3:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, traceback):
|
||||||
|
return None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.objects = {}
|
self.objects = {}
|
||||||
|
|
||||||
def put_object(self, **kwargs):
|
async def put_object(self, **kwargs):
|
||||||
self.objects[kwargs["Key"]] = kwargs["Body"]
|
self.objects[kwargs["Key"]] = kwargs["Body"]
|
||||||
|
|
||||||
fake = FakeS3()
|
fake = FakeS3()
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Persistent SQLite configuration and migration integration tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from contextlib import closing
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kca.config import _database_url_from_parts
|
||||||
|
from kca.models import Vault
|
||||||
|
from kca.tortoise_mixin import TortoiseMixin
|
||||||
|
from tortoise.context import TortoiseContext
|
||||||
|
|
||||||
|
SERVER_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MIGRATIONS_DIR = SERVER_ROOT / "migrations" / "models"
|
||||||
|
|
||||||
|
|
||||||
|
class SqliteConfigurationTests(unittest.TestCase):
|
||||||
|
def test_database_url_from_sqlite_parts(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
_database_url_from_parts(
|
||||||
|
"sqlite", "ignored", "ignored", "ignored", "5432", "/data/kca.db", "busy_timeout=5000"
|
||||||
|
),
|
||||||
|
"sqlite:///data/kca.db?busy_timeout=5000",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_database_url_from_parts("sqlite", "", None, "", "", "data/kca.db", ""),
|
||||||
|
"sqlite://data/kca.db",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_database_url_from_parts("sqlite", "", None, "", "", ":memory:", ""),
|
||||||
|
"sqlite://:memory:",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SqliteMemoryTests(unittest.TestCase):
|
||||||
|
def test_memory_url_with_options_generates_schemas(self) -> None:
|
||||||
|
async def create_vault() -> None:
|
||||||
|
mixin = TortoiseMixin("sqlite://:memory:?busy_timeout=5000", ["kca.models"])
|
||||||
|
await mixin.ensure_context()
|
||||||
|
try:
|
||||||
|
await Vault.create(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
owner_issuer="https://issuer.example",
|
||||||
|
owner_sub="memory-user",
|
||||||
|
name="Memory vault",
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.assertEqual(await Vault.all().count(), 1)
|
||||||
|
finally:
|
||||||
|
await mixin.aclose()
|
||||||
|
|
||||||
|
asyncio.run(create_vault())
|
||||||
|
|
||||||
|
|
||||||
|
class SqliteFileMigrationTests(unittest.TestCase):
|
||||||
|
def test_migrations_pragmas_and_persistence(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
db_path = Path(tmp) / "kca.db"
|
||||||
|
database_url = f"sqlite://{db_path}?busy_timeout=5000"
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["DATABASE_URL"] = database_url
|
||||||
|
aerich = Path(sys.executable).with_name("aerich")
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(aerich), "upgrade"],
|
||||||
|
cwd=SERVER_ROOT,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||||
|
|
||||||
|
expected_tables = {
|
||||||
|
"aerich",
|
||||||
|
"api_token",
|
||||||
|
"app_user",
|
||||||
|
"authority",
|
||||||
|
"certificate",
|
||||||
|
"certificate_revocation",
|
||||||
|
"crl",
|
||||||
|
"key",
|
||||||
|
"ocsp_snapshot",
|
||||||
|
"request",
|
||||||
|
"template",
|
||||||
|
"vault",
|
||||||
|
}
|
||||||
|
migration_count = len(list(MIGRATIONS_DIR.glob("[0-9]_*.py")))
|
||||||
|
with closing(sqlite3.connect(db_path)) as db:
|
||||||
|
tables = {
|
||||||
|
row[0]
|
||||||
|
for row in db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.assertEqual(tables, expected_tables)
|
||||||
|
self.assertEqual(db.execute("SELECT COUNT(*) FROM aerich").fetchone()[0], migration_count)
|
||||||
|
certificate_columns = {row[1] for row in db.execute("PRAGMA table_info(certificate)")}
|
||||||
|
authority_columns = {row[1] for row in db.execute("PRAGMA table_info(authority)")}
|
||||||
|
user_columns = {row[1] for row in db.execute("PRAGMA table_info(app_user)")}
|
||||||
|
self.assertIn("no_expiration", certificate_columns)
|
||||||
|
self.assertIn("template_key", authority_columns)
|
||||||
|
self.assertIn("issuer", user_columns)
|
||||||
|
|
||||||
|
vault_id = uuid.uuid4()
|
||||||
|
|
||||||
|
async def write_vault() -> None:
|
||||||
|
ctx = TortoiseContext()
|
||||||
|
with ctx:
|
||||||
|
await ctx.init(db_url=database_url, modules={"models": ["kca.models"]})
|
||||||
|
try:
|
||||||
|
connection = ctx.connections.get("default")
|
||||||
|
foreign_keys = await connection.execute_query_dict("PRAGMA foreign_keys")
|
||||||
|
journal_mode = await connection.execute_query_dict("PRAGMA journal_mode")
|
||||||
|
busy_timeout = await connection.execute_query_dict("PRAGMA busy_timeout")
|
||||||
|
self.assertEqual(foreign_keys[0]["foreign_keys"], 1)
|
||||||
|
self.assertEqual(journal_mode[0]["journal_mode"].lower(), "wal")
|
||||||
|
self.assertEqual(busy_timeout[0]["timeout"], 5000)
|
||||||
|
await Vault.create(
|
||||||
|
id=vault_id,
|
||||||
|
owner_issuer="https://issuer.example",
|
||||||
|
owner_sub="sqlite-user",
|
||||||
|
name="Persistent vault",
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await ctx.close_connections()
|
||||||
|
|
||||||
|
asyncio.run(write_vault())
|
||||||
|
with closing(sqlite3.connect(db_path)) as db:
|
||||||
|
row = db.execute("SELECT name FROM vault WHERE id = ?", (str(vault_id),)).fetchone()
|
||||||
|
self.assertEqual(row, ("Persistent vault",))
|
||||||
|
|
||||||
|
# SQLite bootstraps at the current schema, so historical downgrades
|
||||||
|
# are executable no-ops and preserve both the schema and its data.
|
||||||
|
for _ in range(migration_count - 1):
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(aerich), "downgrade", "--yes"],
|
||||||
|
cwd=SERVER_ROOT,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(aerich), "upgrade"],
|
||||||
|
cwd=SERVER_ROOT,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||||
|
with closing(sqlite3.connect(db_path)) as db:
|
||||||
|
self.assertEqual(db.execute("SELECT COUNT(*) FROM aerich").fetchone()[0], migration_count)
|
||||||
|
row = db.execute("SELECT name FROM vault WHERE id = ?", (str(vault_id),)).fetchone()
|
||||||
|
self.assertEqual(row, ("Persistent vault",))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Generated
+935
-102
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,10 @@ struct ExtraSubjectRow {
|
|||||||
value: Signal<String>,
|
value: Signal<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn key_is_selectable(key: &KeySummary, include_used: bool) -> bool {
|
||||||
|
key.has_private && (include_used || key.use_count == 0)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct CertFormSignals {
|
struct CertFormSignals {
|
||||||
explicit_dn: Signal<Vec<String>>,
|
explicit_dn: Signal<Vec<String>>,
|
||||||
@@ -94,6 +98,7 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
|||||||
let comment = create_signal(String::new());
|
let comment = create_signal(String::new());
|
||||||
let signer_id = create_signal("self".to_string());
|
let signer_id = create_signal("self".to_string());
|
||||||
let key_id = create_signal(String::new());
|
let key_id = create_signal(String::new());
|
||||||
|
let include_used_keys = create_signal(false);
|
||||||
let new_key = create_signal(false);
|
let new_key = create_signal(false);
|
||||||
let key_type = create_signal("rsa".to_string());
|
let key_type = create_signal("rsa".to_string());
|
||||||
let key_size = create_signal("2048".to_string());
|
let key_size = create_signal("2048".to_string());
|
||||||
@@ -298,6 +303,9 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
|||||||
name.set(format!("{} copy", value.summary.name));
|
name.set(format!("{} copy", value.summary.name));
|
||||||
comment.set(value.summary.comment.clone());
|
comment.set(value.summary.comment.clone());
|
||||||
if let Some(key) = value.summary.key_id.clone() {
|
if let Some(key) = value.summary.key_id.clone() {
|
||||||
|
// XCA includes used keys when copying a certificate so
|
||||||
|
// the original key remains available and selected.
|
||||||
|
include_used_keys.set(true);
|
||||||
key_id.set(key);
|
key_id.set(key);
|
||||||
}
|
}
|
||||||
if let Some(issuer) = value.summary.issuer_id.clone() {
|
if let Some(issuer) = value.summary.issuer_id.clone() {
|
||||||
@@ -594,9 +602,10 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
|||||||
.collect::<Vec<View>>()
|
.collect::<Vec<View>>()
|
||||||
};
|
};
|
||||||
let key_options = move || {
|
let key_options = move || {
|
||||||
|
let include_used = include_used_keys.get();
|
||||||
keys.get_clone()
|
keys.get_clone()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|key| key.has_private)
|
.filter(|key| key_is_selectable(key, include_used))
|
||||||
.map(|key| {
|
.map(|key| {
|
||||||
view! {
|
view! {
|
||||||
option(value=key.id.clone()) { (key.name.clone()) }
|
option(value=key.id.clone()) { (key.name.clone()) }
|
||||||
@@ -604,6 +613,22 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
|||||||
})
|
})
|
||||||
.collect::<Vec<View>>()
|
.collect::<Vec<View>>()
|
||||||
};
|
};
|
||||||
|
create_effect(move || {
|
||||||
|
if include_used_keys.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let selected = key_id.get_clone();
|
||||||
|
if selected.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let selected_is_used = keys
|
||||||
|
.get_clone()
|
||||||
|
.into_iter()
|
||||||
|
.any(|key| key.id == selected && key.use_count > 0);
|
||||||
|
if selected_is_used {
|
||||||
|
key_id.set(String::new());
|
||||||
|
}
|
||||||
|
});
|
||||||
let template_options = move || {
|
let template_options = move || {
|
||||||
templates.get_clone().into_iter().map(|template| view! {
|
templates.get_clone().into_iter().map(|template| view! {
|
||||||
option(value=template.id.clone()) { (if template.builtin { format!("[built-in] {}", template.name) } else { template.name.clone() }) }
|
option(value=template.id.clone()) { (if template.builtin { format!("[built-in] {}", template.name) } else { template.name.clone() }) }
|
||||||
@@ -728,6 +753,10 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
|
|||||||
option(value="") { "— select a key —" }
|
option(value="") { "— select a key —" }
|
||||||
(key_options())
|
(key_options())
|
||||||
}
|
}
|
||||||
|
div(class="form-check mt-2") {
|
||||||
|
input(class="form-check-input", r#type="checkbox", id="include-used-keys", bind:checked=include_used_keys)
|
||||||
|
label(class="form-check-label", r#for="include-used-keys") { "Include used keys" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1502,3 +1531,31 @@ fn query_param(name: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn key(has_private: bool, use_count: i64) -> KeySummary {
|
||||||
|
KeySummary {
|
||||||
|
has_private,
|
||||||
|
use_count,
|
||||||
|
..KeySummary::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_selection_hides_used_and_public_keys_by_default() {
|
||||||
|
assert!(key_is_selectable(&key(true, 0), false));
|
||||||
|
assert!(!key_is_selectable(&key(true, 1), false));
|
||||||
|
assert!(!key_is_selectable(&key(false, 0), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn key_selection_includes_used_private_keys_when_requested() {
|
||||||
|
assert!(key_is_selectable(&key(true, 0), true));
|
||||||
|
assert!(key_is_selectable(&key(true, 2), true));
|
||||||
|
assert!(!key_is_selectable(&key(false, 0), true));
|
||||||
|
assert!(!key_is_selectable(&key(false, 1), true));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user