Editions and extensions¶
The Bor server can be built into other distributions without patching it.
The community binary is a thin main package around a reusable server
package, and two public packages define what a distribution may change:
which optional features are on (server/pkg/edition) and which extra
modules run inside the server (server/pkg/ext). This page describes those
extension points as they exist in this repository.
flowchart TB
M["distribution main package"] -->|"app.Run(Options)"| APP["server/pkg/app"]
APP --> ED["Options.Edition (pkg/edition)"]
APP --> EXT["Options.Extensions []ext.Module"]
EXT -->|"Init(ctx, Host)"| H["internal/exthost Host"]
H --> R1["HandlePublic / HandleAdmin<br/>/api/v1/ext/NAME/"]
H --> R2["Logins(): login methods, account claims"]
H --> R3["RegisterSettingsPanel"]
H --> R4["Identity, Audit, Secrets, DB, RunMigrations"]
ED --> ME["/api/v1/auth/me: edition + features"]
| Package | Role |
|---|---|
server/pkg/app |
The whole server: app.Run(app.Options) and app.ResetMFA(username) |
server/pkg/edition |
The Edition interface, the Community edition and the feature flags |
server/pkg/ext |
The extension API: Module, Host and the types a module uses |
server/internal/exthost |
The server-side implementation of Host (not importable from outside the module) |
The server entry point: pkg/app¶
server/cmd/server/main.go is the complete community entry point:
func main() {
resetMFAUser := flag.String("reset-mfa", "", "Disable MFA for the given username and exit")
flag.Parse()
if *resetMFAUser != "" {
if err := app.ResetMFA(*resetMFAUser); err != nil {
log.Fatalf("reset-mfa failed: %v", err)
}
return
}
app.Run(app.Options{Version: Version, Edition: edition.Community{}})
}
app.Run loads the configuration, prepares the database, PKI and
services, serves the web UI, REST API and both gRPC listeners, and blocks
until SIGINT or SIGTERM, then shuts down gracefully. Startup failures
are fatal. Its options:
type Options struct {
// Version is reported in logs, the UI and /api/v1/version.
Version string
// Edition selects the optional features the server runs with; nil means
// the community edition (no optional features).
Edition edition.Edition
// Extensions are optional modules (login methods, routes, storage)
// initialized at startup; see pkg/ext. The community edition has none.
Extensions []ext.Module
}
An empty Version becomes dev. The community build sets it with
-ldflags "-X main.Version=x.y.z".
Editions: pkg/edition¶
type Edition interface {
// Name is a short, human-readable edition name such as "community".
Name() string
// Enabled reports whether the feature is available right now.
Enabled(f Feature) bool
}
edition.Community{} returns "community" and enables nothing.
Enabled is consulted per request, not once at startup, so an edition can
switch a feature on or off at run time (for example when a license
expires) without a restart.
Features¶
Feature is a string type. The known features are listed in
edition.AllFeatures; there is currently one:
NodeGroupScopedRBAC("node_group_scoped_rbac")-
Role bindings scoped to a single node group (delegated administration). When it is off, only global role bindings can be created, and existing node-group-scoped bindings grant nothing. The server passes the check into the authorizer (
authz.New) and into the auth, user-group and role-binding services, all wired inserver/pkg/app/app.go:
To add a feature, declare a new Feature constant, append it to
AllFeatures, and gate the behavior on ed.Enabled(...) at request time.
How the web UI learns the edition¶
GET /api/v1/auth/me returns the edition name and the features that are
enabled at that moment, next to the user's permissions:
{
"id": "...",
"username": "admin",
"permissions": ["policy:view", "..."],
"permission_scopes": {"...": "..."},
"edition": "community",
"features": []
}
features is always an array (empty in the community edition), filled
from edition.EnabledFeatures(ed) in AllFeatures order. The web UI
stores it in apiClient/permissions.ts and checks it with
hasFeature(FEATURE_NODE_GROUP_SCOPED_RBAC). With the feature off, the
scope picker and scope column of role bindings are hidden unless scoped
bindings already exist; those stay visible and removable, labeled
"(inactive in this edition)" (components/RoleScopeFields.tsx).
Extensions: pkg/ext¶
An extension is a Go value that implements ext.Module:
type Module interface {
// Name is a short, stable identifier ([a-z0-9-]+). It scopes the
// module's routes (/api/v1/ext/<name>/...) and migrations.
Name() string
// Init is called once at startup, after the core services exist and
// before the server accepts requests. An error aborts startup.
Init(ctx context.Context, host Host) error
}
app.Run passes Options.Extensions to exthost.Init, which checks each
name (^[a-z0-9][a-z0-9-]{0,31}$, unique across modules), creates a Host
bound to that name and calls Init in order. Everything a module mounts,
stores or logs goes through its Host, so the server keeps control of
routing, authorization, sessions and auditing.
What a module can register¶
- Routes
HandlePublic(pattern, handler)mounts an unauthenticated handler, for example an identity provider callback; the handler authenticates requests itself and the route is exempt from the CSRF check.HandleAdmin(pattern, resource, action, handler)mounts a handler behind the session middleware and a globalresource:actionpermission check (for examplesettings,manage); state-changing requests are recorded by the audit middleware. Every pattern must be a plain path under/api/v1/ext/<name>/; a pattern outside the prefix or a duplicate is returned as an error.- Login methods
Logins().Register(ext.LoginMethod{ID, DisplayName, StartURL, Enabled})adds a "Sign in with ..." button to the login page. The methods are served to the login page byGET /api/v1/config(login_methods);Enabledis evaluated on every page load, andStartURLmust be a route of the module.- Account claims and other login hooks
-
For federated accounts the module owns (
User.FederationProvider):ClaimAccounts(provider, startURL): when such a user enters their username, the login page goes tostartURL(username)instead of asking for a password.ClaimStepUp(provider, startURL): before a privileged action, the web UI opensstartURL(username, purpose)in a popup instead of asking for a password the user does not have; the module callsIdentity().CompleteStepUp(...)and redirects toext.StepUpDoneURL(result).ClaimLogout(provider, logoutURL): after such a user logs out of Bor, the browser goes tologoutURLso the module can end the identity provider session (single logout); the route callsIdentity().ConsumeLogoutTicket(...).
- Settings panels
RegisterSettingsPanel(ext.SettingsPanel{ID, Title, DataURL})adds a tab to Settings for users withsettings:manage. The web UI lists the panels fromGET /api/v1/ext-panelsand renders each one with the genericExtensionPanelTab(views/Settings/ExtensionPanelTab.tsx).DataURLmust be aHandleAdminroute of the module that returns anext.Panelas JSON: sections with a status, read-only fields (with an optional copy button), tables and actions. An action is a button that sendsMethodto a module URL and reloads the panel; aDangeraction requires typing itsConfirmPhrase, andResult: "secret"shows a returned token once with a copy control.- Storage
DB()returns the server's*sql.DBfor the module's own tables.RunMigrations(fsys, dir)applies the module's*.up.sqlfiles in name order, once each, each in its own transaction. They are tracked per module inext_schema_migrations, so their numbering is independent of the server's.- Users, groups and sessions
Identity()manages federated accounts: find, create, update, rename, link (LinkFederatedIdentity,LinkAccountByEmail), enable or disable, revoke sessions, delete (for erasure requests); module-owned user groups (CreateManagedGroupand related methods, owned per module); group membership sync; andStartSession, which signs a user in exactly like the built-in login (session, refresh and CSRF cookies plus anauth.loginaudit event) and applies Bor's own second factor according toAuthContext.LocalMFA. Account changes made throughIdentity()are audited with the module as actor, or the actor set withext.WithActor(ctx, ...).- Audit and secrets
Audit().Record(ctx, r, &ext.Event{...})writes an audit event (never put secrets inDetails).Secrets()returns an AES-256-GCM box whose key is derived per module with HKDF-SHA256 from the server's JWT secret, for encrypting small values such as client secrets at rest.RequestUser(r)returns the signed-in user inside aHandleAdminroute.
The community edition registers no modules, so /api/v1/ext-panels
returns an empty list and the login page shows no extra buttons.
Building a distribution¶
A distribution is a separate Go module with its own main package that
imports github.com/VuteTech/Bor/server/pkg/app, supplies its own
Edition and modules, and calls app.Run:
package main
import (
"context"
"flag"
"log"
"net/http"
"github.com/VuteTech/Bor/server/pkg/app"
"github.com/VuteTech/Bor/server/pkg/edition"
"github.com/VuteTech/Bor/server/pkg/ext"
)
var Version = "dev"
// myEdition enables delegated administration.
type myEdition struct{}
func (myEdition) Name() string { return "example" }
func (myEdition) Enabled(f edition.Feature) bool {
return f == edition.NodeGroupScopedRBAC
}
// hello is a minimal module with one admin route.
type hello struct{}
func (hello) Name() string { return "hello" }
func (hello) Init(_ context.Context, h ext.Host) error {
return h.HandleAdmin("/api/v1/ext/hello/ping", "settings", "manage",
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
}
func main() {
resetMFAUser := flag.String("reset-mfa", "", "Disable MFA for the given username and exit")
flag.Parse()
if *resetMFAUser != "" {
if err := app.ResetMFA(*resetMFAUser); err != nil {
log.Fatalf("reset-mfa failed: %v", err)
}
return
}
app.Run(app.Options{
Version: Version,
Edition: myEdition{},
Extensions: []ext.Module{hello{}},
})
}
Points to keep in mind:
- Only
pkg/app,pkg/edition,pkg/extand the generatedpkg/grpc/...packages are importable; everything underserver/internal/is private to the server module by Go's rules. - Keep the
--reset-mfaflag (or an equivalent) so administrators keep the recovery path documented for the community server. - The web UI is embedded in
pkg/appthroughserver/web, so a distribution ships the same frontend; extensions add UI only through settings panels and login methods. - Build with the same flags as the community server (see
Building and packaging), in particular
GOFIPS140=v1.0.0, and set the version with-ldflags "-X main.Version=...".