Skip to content

Repository files navigation

isabelle-core

Build Status

Isabelle is a Rust-based framework for building safe and performant servers for the variety of use cases.

Features

  • Unified item storage with addition, editing and deletion support.
  • Collection hooks allowing plugins to do additional checks or synchronization.
  • Security checks.
  • E-Mail sending support.
  • Google Calendar integration.
  • Login/logout functionality.
  • One-time password support.
  • Signing in with Google and Apple.
  • Signing in against an LDAP directory.
  • Self-describing HTTP API: an OpenAPI 3.1 document generated from the running deployment, plugin routes included.
  • Feature gate declared on disk in features.js, readable over the API and writable only by whoever installs the deployment.

API description

The full HTTP surface of a running deployment is described by an OpenAPI 3.1 document the server generates on request:

  • GET /openapi.json — the document itself.
  • GET /docs — the same thing rendered as a static page (no scripts, no external assets, works offline).

Both are public. A description is not a credential: every endpoint it names still authenticates its own callers, so publishing it opens nothing. It does say which plugin routes exist and which collections the store holds — a deployment that would rather not publish that starts core with --openapi-private, which serves both to administrators only.

It is generated rather than committed because half of the surface only exists at runtime: each extra_route / extra_unprotected_route / extra_rest_route in internals.js becomes a real path at startup, and the collection parameter is constrained to the collections the store actually holds. The short list below covers the endpoints core itself always has.

Endpoints

  1. GET /is_logged_in: check the login status.

Result:

{
	"username": "<username>",
	"id": <user id>,
	"role": [ "role_is_admin" ],
	"site_name": "Test",
	"site_logo": "Test Logo"
	"licensed_to": "Test Company"
}
  1. POST /login (username, password inside the post request):
{
	"succeeded": true/false,
	"error": "detailed error",
}
  1. POST /logout:

  2. GET /itm/list (collection, [id], [id_min], [id_max], [skip], [limit], [sort_key], [filter]): read the item from the collection

{
	"map": [ <id>: {} ],
	"total_count": <value>
}
  1. POST /itm/edit ("item" inside the post request and inside the query string, "collection" and "merge" = false/true in query): edit the item in collection.
{
	"succeeded": true/false,
	"error": "detailed error",
}
  1. POST /itm/del (collection, id): delete the item from the collection
{
	"succeeded": true/false,
	"error": "detailed error",
}

Signing in with Google or Apple

Core speaks the OpenID Connect authorization-code flow, so an account can be signed into with a Google or Apple identity instead of a password. Nothing is enabled by default: a provider exists for a deployment exactly when its entry exists in the encrypted secret store, and there is no second switch to leave in the wrong position.

Configuring a provider

GET /auth/config and POST /auth/config are the administrator-only pair for this (proteos puts a form on them under Settings → Sign-in). The read returns each provider's public settings and whether a secret is stored — never the secret itself. The write takes only the fields being changed, and checks the result before storing it, so an unreadable Apple key is refused at the moment someone saves it rather than weeks later at a token endpoint. POST /auth/config/forget removes a provider, which is also how it is switched off.

Because a client secret can never be read back, an empty one in a write means "leave what is stored alone" — an empty box on a screen that was never allowed to show the value is not a decision to delete it.

Underneath, each provider is one entry in the encrypted secret store, so POST /secret/edit works too and is what a script would use.

Google — the entry is named oauth_google:

key what it is
client_id the OAuth client ID from the Google Cloud console
client_secret its secret
redirect_uri optional; see below

Apple — the entry is named oauth_apple. Apple issues no client secret; it wants a short-lived JWT signed with a key you register, so what is stored is the key itself:

key what it is
client_id the Services ID, e.g. io.example.web
team_id the Apple developer team
key_id the identifier of the Sign in with Apple key
private_key the contents of its .p8 file, PEM and all
redirect_uri optional; see below

The redirect URI

This is the address the browser comes back to, and it has to be registered with the provider character for character. Left unset, core uses <--pub-url>/api/auth/<provider>/callback — where the shipped nginx configuration puts it. A deployment that serves core somewhere else sets redirect_uri explicitly. Apple will not register an http:// address at all, so Apple sign-in needs TLS even in development.

What it does

  • GET /auth/providers — what a login screen should offer. No session needed, and it says only that a provider is configured, never with what.
  • GET /auth/config, POST /auth/config, POST /auth/config/forget — reading and writing the above. Administrators only.
  • GET /auth/{provider}/start?next=/where — a redirect to the provider.
  • GET|POST /auth/{provider}/callback — where the provider returns the browser. On success a session cookie is issued and the browser goes to next; otherwise to the same place with auth_error set to one of denied, unverified, registration_closed, inactive, mismatched or failed. The detail behind a refusal is logged rather than put in a URL.

Signing in against a directory

A directory is not a provider: there is no redirect and no button. People type their username and password into the ordinary form, and the password is checked by binding to the directory with it. So it hangs off /login, and it is tried only when the local check has not already let them in — which means a deployment that has always worked keeps working whatever state the directory is in, and an administrator with a password here can still get in when the directory is the thing that is broken.

It is configured through the same /auth/config with provider: "ldap", and stored as one secret-store entry named ldap:

key what it is
url ldaps://directory.example.com, or ldap://
base_dn where to search
user_filter how to search, %u standing for what was typed — (uid=%u), (sAMAccountName=%u)
user_dn_template the other shape: build the DN and bind straight to it, uid=%u,ou=people,dc=example,dc=com
bind_dn the service account that does the searching; blank searches anonymously
bind_password its password
email_attribute default mail
name_attribute default cn
allow_plaintext required before ldap:// will be accepted

Fill in either a base DN with a filter, or a DN template. An entry with no address cannot be signed in: an account here is an email address, and there would be nothing to make one out of.

Three things are refused on purpose. A blank password never reaches the directory, because a bind with a DN and no password is an anonymous bind that succeeds and proves nothing. Values interpolated into a DN or a filter are escaped, so a username of * is a username and not every entry in the tree. And ldap:// has to be asked for in as many words, because every password typed crosses that connection in clear.

What a provider is trusted for

The identity, and nothing else — who the account is and what address it has. Roles and activity belong to the record here, so no provider can make anyone an administrator or revive a disabled account.

The same policy governs a directory sign-in, with the directory as the thing that vouched. An address the source will not vouch for is not an identity, and is refused. A verified one signs into the account that already holds it; if there is none, one is created when allow_self_registration permits it. The provider's own identifier for the account is remembered on first sign-in, and a later sign-in presenting a different one for the same address is refused — so an address that changes hands at the provider cannot pick up the record left behind.

What a deployment may do: features.js

Which features a deployment has is an installation decision, so it is written in a file and nowhere else. features.js lives in the data directory, beside settings.js and internals.js, and core only ever reads it. There is no /feature/edit to go with /setting/edit: no session, no API token and no plugin can grant a feature that was not granted on disk, and its absence from the API is the guarantee rather than a gap.

It is a JSON object. Each key is a feature name; each value is that feature's descriptor, in whatever shape the feature wants:

{
  "reports": { "formats": ["pdf", "csv"], "retention_days": 90 },
  "sso": { "tenant": "acme" },
  "beta_ui": null
}

Core interprets exactly one thing here — the set of keys. Descriptors are carried through untouched, so there is no schema to violate and no format to migrate; a feature's own code is the only thing that has to understand its descriptor.

  • GET /feature/list — the declared names, sorted, as a JSON array. Any signed-in caller: the UI a normal user looks at is the main thing that needs to know which features exist. An API token needs the read scope.

Descriptors are not served. They can hold quotas, tenant names and licence detail that the feature's implementation needs and a browser does not, so they stay on the server: core reads them through Data::features(), and a plugin asks for them over the plugin channel — CoreHandle::features_all(), with features_get, features_has and features_list beside it. Nothing in either direction writes them.

The file is read once, at startup, exactly as internals.js is — an operator who edits it restarts core, and until then every reader in the process agrees about what the deployment may do.

Two consequences worth stating plainly, both deliberate:

  • A deployment with no features.js declares no features. Empty is the safe direction for a list that says what is permitted — unlike an empty settings.js, which means defaults. A missing file is logged as a warning and an unparseable one as an error, because those are the two ways a deployment that should have features ends up with none.
  • Updates must leave the data directory alone. Core never writes features.js, so nothing inside the server can lose it; what can is an update script (--update-script, POST /system/update) that replaces more than the binary. The same already applies to settings.js, internals.js, secrets.enc and the key files, so a script that is safe for those is safe for this.

Dependencies

  • Python 3 is needed for Google Calendar integration

Building

Building Isabelle is as easy as Cargo invocation:

cargo build

Running

Use run.sh script:

./run.sh

License

MIT

About

Isabelle Core

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages