Compare commits

..
9 Commits
Author SHA1 Message Date
root-at-skic fddf3ed571 README: link to the articles on Medium 2026-11-09 00:03:42 +02:00
root-at-skic fae1389657 README: coffee button at the top 2026-11-08 22:06:56 +02:00
root-at-skic 2fbd3a024d README: buy me a coffee 2026-11-08 22:04:44 +02:00
root-at-skic dd6c454d23 README: sign-in through 127.0.0.1, SSO without setup or passwords, the Argo CD template 2026-11-08 21:53:26 +02:00
root-at-skic 7da2be887d Sign-in ends on a page with a button back to the editor
Browsers open an app link only after a click on the page. With a live
identity provider session the whole sign-in ran without one, so the
redirect to vscode:// was dropped and the editor never got its code. The
page opens the editor at once and keeps a button for when that is held
back. Server 0.1.4, chart 0.1.5.
2026-11-08 19:56:53 +02:00
root-at-skic 994df0d675 Sign-in goes straight to the identity provider
With single sign-on there is nothing to choose, so /authorize redirects to
it; the page shows only when something went wrong there.
Server 0.1.3, chart 0.1.4.
2026-11-08 19:43:07 +02:00
root-at-skic 7c86198002 Single sign-on is the only way in when it is configured
The sign-in page shows only the identity provider; password sign-in and
new local accounts are refused. Invites go through the identity provider.
Server 0.1.2, chart 0.1.3.
2026-11-08 19:39:02 +02:00
root-at-skic 6114a994db Single sign-on servers need no setup step
With single sign-on there is no first-start code and no /setup form: the
identity provider says who may sign in and who is admin, and the first
allowed sign-in makes the organisation, named after ARCHIFY_SYNC_NAME.
Server 0.1.1, chart 0.1.2.
2026-11-08 19:34:48 +02:00
root-at-skic 6baa46bc28 Chart: postgresql.existingClaim for a database volume made outside the chart 2026-11-06 21:23:49 +02:00
9 changed files with 113 additions and 35 deletions

No files matched your search

Generated
+3 -3
View File
@@ -43,7 +43,7 @@ dependencies = [
[[package]] [[package]]
name = "archify-sync-protocol" name = "archify-sync-protocol"
version = "0.1.0" version = "0.1.4"
dependencies = [ dependencies = [
"chrono", "chrono",
"serde", "serde",
@@ -53,7 +53,7 @@ dependencies = [
[[package]] [[package]]
name = "archify-sync-server" name = "archify-sync-server"
version = "0.1.0" version = "0.1.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archify-sync-protocol", "archify-sync-protocol",
@@ -83,7 +83,7 @@ dependencies = [
[[package]] [[package]]
name = "archify-sync-store" name = "archify-sync-store"
version = "0.1.0" version = "0.1.4"
dependencies = [ dependencies = [
"chrono", "chrono",
"serde_json", "serde_json",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/protocol", "crates/store", "crates/server"] members = ["crates/protocol", "crates/store", "crates/server"]
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.1.4"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync" repository = "https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync"
+19 -9
View File
@@ -1,8 +1,12 @@
# Archify Sync # Archify Sync
[![Buy me a coffee](https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png)](https://buymeacoffee.com/root.at.skic)
The server behind live, shared models in [Archify Diagram Viewer](https://marketplace.visualstudio.com/items?itemName=root-at-skic.archify-vscode-ext). People edit one model at the same time and see each other type. Every edit is kept, versions are taken on their own, and anyone can publish one by hand. The server behind live, shared models in [Archify Diagram Viewer](https://marketplace.visualstudio.com/items?itemName=root-at-skic.archify-vscode-ext). People edit one model at the same time and see each other type. Every edit is kept, versions are taken on their own, and anyone can publish one by hand.
One Rust process and PostgreSQL. The design, with its decisions, is the Archify model in `ws-storm/2026-10-30-archify-sync-backend/`. One Rust process and PostgreSQL.
How sharing, sign-in and setup fit together, as a walkthrough: [articles on Medium](https://medium.com/@root.at.skic).
## How it works ## How it works
@@ -14,9 +18,9 @@ One Rust process and PostgreSQL. The design, with its decisions, is the Archify
- **Publish (1.0, 2.0, …):** marked by hand, with a name. - **Publish (1.0, 2.0, …):** marked by hand, with a name.
- **Restore:** brings an earlier version back as a new edit and a new version; history is never rewritten. - **Restore:** brings an earlier version back as a new edit and a new version; history is never rewritten.
- **Update log:** taking a version compacts it. - **Update log:** taking a version compacts it.
- **Sign-in.** The editor opens `/authorize` in the browser and gets a one-time code back through its URI handler. It exchanges the code with its PKCE verifier for an access token (JWT, 15 minutes) and a refresh token. The refresh token is single use: reusing one signs out the whole session. - **Sign-in.** The editor opens `/authorize` in the browser and gets a one-time code back: on the desktop through a one-off listener on `127.0.0.1`, in remote and web editors through its URI handler (`vscode://…/auth`). After sign-in the server shows a page that opens the editor, with a button for when the browser holds the link back. The editor exchanges the code with its PKCE verifier for an access token (JWT, 15 minutes) and a refresh token. The refresh token is single use: reusing one signs out the whole session.
- **First start.** A server without accounts prints a first-start code in its log. The first person to use it at `/setup` becomes the admin. - **First start.** With single sign-on there is no setup step: the identity provider says who may sign in and who is admin, and the first sign-in makes the organisation (named after `ARCHIFY_SYNC_NAME`). Without single sign-on, a server without accounts prints a first-start code in its log; the first person to use it at `/setup` becomes the admin.
- **Invites.** An invite link (`/join/<token>`) opens the extension. Someone new makes an account on the sign-in page, so no mail server is needed. - **Invites.** An invite link (`/join/<token>`) opens the extension. Someone new makes an account on the sign-in page, so no mail server is needed. With single sign-on, the identity provider is the only way in: sign-in goes straight to it, with no password form, and an invited person signs in there.
## Layout ## Layout
@@ -36,7 +40,7 @@ docker run -d --name archify-pg -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=archify
ARCHIFY_SYNC_DATABASE_URL=postgres://postgres:dev@127.0.0.1:5432/archify cargo run -p archify-sync-server ARCHIFY_SYNC_DATABASE_URL=postgres://postgres:dev@127.0.0.1:5432/archify cargo run -p archify-sync-server
``` ```
The log shows the first-start code. Open `http://localhost:8080/setup` and enter it. Without single sign-on, the log shows the first-start code. Open `http://localhost:8080/setup` and enter it.
Or the server and its database in containers: Or the server and its database in containers:
@@ -47,7 +51,7 @@ docker compose logs server | grep "with code"
## Kubernetes ## Kubernetes
The homelab deployment is in [archify-vscode-ext-sync-gitops](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync-gitops) (rendered from `archify-vscode-ext-sync-gitops-tmpl`); Argo CD takes this chart from here. For Argo CD, [archify-vscode-ext-sync-gitops-tmpl](https://gitea.lego-cloud.eu/vscode-extensions/archify-vscode-ext-sync-gitops-tmpl) is a Copier template that renders an app-of-apps with this chart, its values and, optionally, its secrets through External Secrets.
The chart is in `charts/archify-sync`. It always runs one pod with the `Recreate` strategy: one server holds the live copy of each space, and the extension works offline while the pod is replaced. Probes use `/healthz` and `/readyz`; on `SIGTERM` the server stores a snapshot of every open space. The chart is in `charts/archify-sync`. It always runs one pod with the `Recreate` strategy: one server holds the live copy of each space, and the extension works offline while the pod is replaced. Probes use `/healthz` and `/readyz`; on `SIGTERM` the server stores a snapshot of every open space.
@@ -57,7 +61,7 @@ helm install sync charts/archify-sync -n archify-sync --create-namespace \
--set database.existingSecret=archify-sync-db # a Secret with the URL under "url" --set database.existingSecret=archify-sync-db # a Secret with the URL under "url"
``` ```
`postgresql.enabled=true` adds a single PostgreSQL to the release, for trying out. Behind ingress-nginx, raise `proxy-read-timeout` and `proxy-send-timeout` (see `values.yaml`): edits travel over a WebSocket that stays open. `postgresql.enabled=true` adds a single PostgreSQL to the release, for trying out. `postgresql.existingClaim` puts its data on a claim made outside the chart; PostgreSQL runs as uid 70 and needs that directory owned by 70, mode 0700, so an NFS share must not map every user to root. Behind ingress-nginx, raise `proxy-read-timeout` and `proxy-send-timeout` (see `values.yaml`): edits travel over a WebSocket that stays open.
On a local cluster (OrbStack, kind, k3d) with the image from `docker build -t archify-sync:local .`: On a local cluster (OrbStack, kind, k3d) with the image from `docker build -t archify-sync:local .`:
@@ -69,7 +73,7 @@ The server is then at `http://localhost:18081`.
## Single sign-on (OIDC) ## Single sign-on (OIDC)
With an OpenID Connect provider (Keycloak, Authentik, Entra ID, …) the sign-in page shows **Sign in with Keycloak** above the password form. Nothing changes in the extension: it opens the same page, and the provider sends the person back to `<public URL>/oidc/callback`. People are matched by the provider's issuer and subject, never by name; a new person gets an account named after `preferred_username` (`ana`, or `ana-2` when `ana` is taken). With an OpenID Connect provider (Keycloak, Authentik, Entra ID, …) the provider is the only way in: `/authorize` goes straight to it, there is no password form, and no local accounts are made. Nothing changes in the extension: it opens the same page, and the provider sends the person back to `<public URL>/oidc/callback`. People are matched by the provider's issuer and subject, never by name; a new person gets an account named after `preferred_username` (`ana`, or `ana-2` when `ana` is taken).
Who gets in: Who gets in:
@@ -77,7 +81,7 @@ Who gets in:
- **No allowed groups**: anyone the provider signs in, but a new person needs an invite link the first time. - **No allowed groups**: anyone the provider signs in, but a new person needs an invite link the first time.
- **Admin groups** (`ARCHIFY_SYNC_OIDC_ADMIN_GROUPS=archify-admins`): their members are admins of the server. - **Admin groups** (`ARCHIFY_SYNC_OIDC_ADMIN_GROUPS=archify-admins`): their members are admins of the server.
The first admin is still set up with a password on `/setup`; keep that account as the way in when the provider is down. There is no `/setup` step: the first sign-in by an allowed or admin member makes the organisation, named after `ARCHIFY_SYNC_NAME`.
### The client at the provider ### The client at the provider
@@ -169,3 +173,9 @@ All settings come from the environment.
## Licence ## Licence
MIT MIT
## Support
Archify Sync is built and maintained in my spare time. If it helps you, a coffee keeps the releases coming:
[![Buy me a coffee](https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png)](https://buymeacoffee.com/root.at.skic)
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: archify-sync name: archify-sync
description: Archify Sync, live and shared Archify models for VS Code description: Archify Sync, live and shared Archify models for VS Code
type: application type: application
version: 0.1.0 version: 0.1.5
appVersion: "0.1.0" appVersion: "0.1.4"
annotations: annotations:
artifacthub.io/license: MIT artifacthub.io/license: MIT
sources: sources:
@@ -97,6 +97,12 @@ spec:
volumes: volumes:
- name: run - name: run
emptyDir: {} emptyDir: {}
{{- with .Values.postgresql.existingClaim }}
- name: data
persistentVolumeClaim:
claimName: {{ . }}
{{- end }}
{{- if not .Values.postgresql.existingClaim }}
volumeClaimTemplates: volumeClaimTemplates:
- metadata: - metadata:
name: data name: data
@@ -109,3 +115,4 @@ spec:
requests: requests:
storage: {{ .Values.postgresql.storage }} storage: {{ .Values.postgresql.storage }}
{{- end }} {{- end }}
{{- end }}
+3
View File
@@ -31,6 +31,9 @@ postgresql:
existingSecret: "" existingSecret: ""
storage: 5Gi storage: 5Gi
storageClass: "" storageClass: ""
# A PersistentVolumeClaim made outside the chart (e.g. a static NFS volume whose
# directory is owned by uid 70, mode 0700); storage and storageClass are then unused.
existingClaim: ""
resources: {} resources: {}
config: config:
+47 -19
View File
@@ -190,6 +190,11 @@ pub async fn authorize_page(
State(state): State<AppState>, State(state): State<AppState>,
Query(q): Query<AuthorizeQuery>, Query(q): Query<AuthorizeQuery>,
) -> Response { ) -> Response {
// With single sign-on there is nothing to choose: straight to the identity
// provider. The page only shows when something went wrong there.
if state.oidc.is_some() && check_authorize(&state, &q).is_ok() {
return Redirect::to(&format!("/oidc/start?{}", q.to_query_string())).into_response();
}
authorize_form(&state, &q, None).await authorize_form(&state, &q, None).await
} }
@@ -215,24 +220,29 @@ async fn authorize_form(state: &AppState, q: &AuthorizeQuery, error: Option<&str
.unwrap_or_default(), .unwrap_or_default(),
] ]
.concat(); .concat();
// Single sign-on first when the server has it; the password form stays for local accounts. // With single sign-on the identity provider is the only way in: no
let mut body = state // password form, no new local accounts. An invite goes through it too.
.oidc if let Some(o) = &state.oidc {
.as_ref() let invited = match &q.invite {
.map(|o| { Some(invite) => match state.store.invite_organisation(&auth::sha256(invite)).await {
format!( Ok(Some((_, space_name))) => format!(
r#"<div class="card"><h2>Sign in for VS Code</h2>{}<a class="button" href="/oidc/start?{}">Sign in with {}</a></div>"#, r#"<p class="muted">You were invited to <b>{}</b>.</p>"#,
escape(&space_name)
),
_ => String::new(),
},
None => String::new(),
};
let body = format!(
r#"<div class="card"><h2>Sign in for VS Code</h2>{}{invited}<a class="button" href="/oidc/start?{}">Sign in with {}</a></div>"#,
error_line(error), error_line(error),
escape(&q.to_query_string()), escape(&q.to_query_string()),
escape(&o.config.label), escape(&o.config.label),
) );
}) return page(state, "Sign in", &body);
.unwrap_or_default(); }
let (title, error) = if body.is_empty() { let mut body = String::new();
("Sign in for VS Code", error) let title = "Sign in for VS Code";
} else {
("Or with a password", None)
};
body.push_str(&format!( body.push_str(&format!(
r#"<div class="card"><h2>{title}</h2>{} r#"<div class="card"><h2>{title}</h2>{}
<form method="post" action="/authorize">{fields}{} <form method="post" action="/authorize">{fields}{}
@@ -269,6 +279,10 @@ pub async fn authorize_submit(
if check_authorize(&state, &q).is_err() { if check_authorize(&state, &q).is_err() {
return authorize_form(&state, &q, None).await; return authorize_form(&state, &q, None).await;
} }
if let Some(o) = &state.oidc {
let message = format!("This server signs in with {} only.", o.config.label);
return authorize_form(&state, &q, Some(&message)).await;
}
let account = match form.action.as_str() { let account = match form.action.as_str() {
"signup" => sign_up(&state, &form).await, "signup" => sign_up(&state, &form).await,
_ => sign_in(&state, &form).await, _ => sign_in(&state, &form).await,
@@ -300,7 +314,18 @@ async fn complete(state: &AppState, q: &AuthorizeQuery, account: &AccountRow) ->
.query_pairs_mut() .query_pairs_mut()
.append_pair("code", &code) .append_pair("code", &code)
.append_pair("state", &q.state); .append_pair("state", &q.state);
Redirect::to(target.as_str()).into_response() // A page, not a redirect: browsers open an app link only after a click on
// the page, and with a live identity provider session nobody clicked. The
// page tries at once and keeps a button for when the browser held back.
let link = escape(target.as_str());
page(
state,
"Signed in",
&format!(
r#"<meta http-equiv="refresh" content="0;url={link}"><div class="card"><h2>Signed in as {}</h2><p class="muted">Your editor finishes the sign-in. If it did not open, use the button.</p><a class="button" href="{link}">Open the editor</a></div>"#,
escape(&account.display_name),
),
)
} }
async fn sign_in(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> { async fn sign_in(state: &AppState, form: &AuthorizeForm) -> Result<AccountRow, ApiErr> {
@@ -531,9 +556,12 @@ async fn sso_account(
.invite_organisation(&auth::sha256(invite)) .invite_organisation(&auth::sha256(invite))
.await? .await?
.map(|(org, _)| org), .map(|(org, _)| org),
None if !config.allowed_groups.is_empty() || admin => { None if !config.allowed_groups.is_empty() || admin => Some(
state.store.first_organisation().await? state
} .store
.organisation_or_create(&state.config.server_name)
.await?,
),
None => None, None => None,
}; };
let Some(organisation) = organisation else { let Some(organisation) = organisation else {
+3 -1
View File
@@ -42,7 +42,9 @@ impl AppState {
let secret = STANDARD.decode(stored.as_str().unwrap_or_default())?; let secret = STANDARD.decode(stored.as_str().unwrap_or_default())?;
anyhow::ensure!(secret.len() >= 32, "the stored signing key is too short"); anyhow::ensure!(secret.len() >= 32, "the stored signing key is too short");
let first_start = if store.account_count().await? == 0 { // With single sign-on the identity provider says who is admin; there
// is no first-start code and no setup page.
let first_start = if config.oidc.is_none() && store.account_count().await? == 0 {
let code = auth::first_start_code(); let code = auth::first_start_code();
tracing::warn!( tracing::warn!(
"first start: no accounts yet. Set up the first admin at {}/setup with code {code}", "first start: no accounts yet. Set up the first admin at {}/setup with code {code}",
+28
View File
@@ -313,6 +313,34 @@ impl Store {
Ok(()) Ok(())
} }
/// The server's organisation, made with `name` when there is none yet
/// (a single sign-on server has no setup step).
pub async fn organisation_or_create(&self, name: &str) -> Result<Uuid> {
let mut tx = self.pool.begin().await?;
// Serialises the first sign-ins, so only one organisation is made.
sqlx::query("LOCK TABLE organisations IN EXCLUSIVE MODE")
.execute(&mut *tx)
.await?;
let found: Option<Uuid> =
sqlx::query_scalar("SELECT id FROM organisations ORDER BY created_at LIMIT 1")
.fetch_optional(&mut *tx)
.await?;
let id = match found {
Some(id) => id,
None => {
let id = Uuid::now_v7();
sqlx::query("INSERT INTO organisations (id, name) VALUES ($1, $2)")
.bind(id)
.bind(name)
.execute(&mut *tx)
.await?;
id
}
};
tx.commit().await?;
Ok(id)
}
/// The server's organisation: the one made at setup. /// The server's organisation: the one made at setup.
pub async fn first_organisation(&self) -> Result<Option<Uuid>> { pub async fn first_organisation(&self) -> Result<Option<Uuid>> {
Ok( Ok(