Building on Kiban¶
Two audiences: application developers using the SDK, and module developers extending the platform.
Apps and modules
Most applications should not be modules. An app runs beside Kiban, registers one manifest and calls the API through an SDK in any language; Integrate your app is that path. This page is the deeper, in-tree contract the four sample modules follow: Go, built by the platform, routed by the gateway. Read it to contribute a sample or to understand how the samples work.
Using the SDK¶
import { createSession, createApiClient, createCapabilitiesClient } from "@rosschiu/kiban-sdk";
const origin = "https://127.0.0.1:8443";
const session = createSession({
authOrigin: origin,
realm: "kiban",
clientId: "kiban-frontend",
redirectUri: "http://localhost:5173/callback.html",
});
// a "Log in" button: await session.login()
// the callback route, once: await session.handleCallback(window.location.href)
const api = createApiClient({
baseUrl: origin,
getAccessToken: () => session.getAccessToken(),
getAccessTokenExpiresAt: () => session.getTokens()?.expiresAt,
refreshAccessToken: () => session.refresh(),
});
const capabilities = await createCapabilitiesClient(api).list();
The session runs the OpenID Connect authorization-code flow with PKCE, validates state and nonce,
refreshes tokens once at a time, survives a reload while a refresh token is still valid, and
logs out at the identity provider. The API client speaks the platform envelope: every success is
{ data }, every failure { error: { code, message } }, surfaced as a KibanApiError with the
HTTP status and code.
Typed clients cover the platform surface: capabilities, effective access, organization reads, and superadmin operations (module enable/disable, platform-role grant and revoke). Two recipes answer common questions in one call:
canI({ featureKey, companyId })returns{ allowed, reason }and never throws for a denial.grantObjectAccess/revokeObjectAccesswrite one tuple, for a user or for a position or group userset. Superadmin only.
The SDK has no runtime dependencies and no dependency on the sample shell. Everything talks to the gateway origin; never call Keycloak or a module directly.
Step by step, with what you should see: the frontend track of Integrate your app. Every client and recipe: the SDK guide.
Writing a module¶
A module is a directory under modules/<key>/ in this repository, with five artifacts, a Go
service and a small embed file:
modules/<key>/
module.manifest.json key, version, scopeType, service basePath/port/healthPath, schema
authz.fragment.json roles, object types, relations, feature keys
openapi.yaml the module's HTTP API under /api/<key>/v1/...
migrations/ tern migrations for schema <key> only, plus tern.conf and checksums.json
frontend/ optional routes a shell can mount (may be absent)
service/ the Go service; service/cmd/main.go is the binary
artifacts.go go:embed of the manifest and the fragment
LICENSE the licence text the manifest declares
In 0.1 a module is compiled into the platform: it is Go, it lives in this repository, its binary is built by the same Dockerfile as the foundation services, and the registry's list of known modules is a Go literal. A module in another language, or one built outside this repository, cannot be installed. The list of edits that turns a directory into a running module is at the end of this section.
This section is the reference. The numbered walk-through, with what you should see at each step, is the backend track of Integrate your app.
The contract in plain terms:
- Authorize every route. Ask the effective-access API before every read and write. Company scope comes from the URL; the decision confirms membership and module enablement, then the relation. A denial is a normal outcome; a dependency failure is a refusal.
- Anchor every object. Each object type your fragment declares carries a
company_modulerelation, and you write that tuple when you create the object. Every object check is bound to the company in the URL. - Grants name the company. A grants request carries
companyIdand the tuples; only your module's own object types (or itscompany_module) are accepted, the caller must pass your module's company-scope decision, and module-tier tuples need the company-module administrator. - Audit every mutation in the same transaction as the write, with the actor taken from the verified token.
- Stay in your schema. Migrations may only touch the module's own schema and its own audit table. They are checksummed once shipped.
- Describe what you serve. The OpenAPI file must match the running code; the test suite validates responses against it.
- Be honest on
/ready. Check your database connection.
For a Go service, import github.com/rosschiu/kiban/modulekit (Apache-2.0). It provides a
JWKS-backed bearer token verifier; JSON decoding, UUID path parameters, paging and unique-violation
helpers; an authorization client (Can, grant and revoke, the anchor tuple for a new object);
an organization client (member lookup by login or id); and a notification client (post a module
custom event). A module is then its handlers, its store and its wrappers. The sample modules
under modules/ show the shape.
Module runtime contract¶
What the gateway sends. For /api/<key>/... the gateway validates the bearer, provisions
the identity record on a subject's first request, checks the catalog (installed, enabled,
dependencies present; otherwise 403 with MODULE_NOT_INSTALLED, MODULE_DISABLED or
MODULE_DEPENDENCY_MISSING and your service is never called) and then reverse-proxies the
request to http://<host>:<service.port>, where <host> is KIBAN_MODULE_HOST_<KEY> on the
gateway or 127.0.0.1 when unset.
| On the forwarded request | Value |
|---|---|
| Path and query | Byte for byte the client's, so your service sees the full /api/<key>/v1/..., never a stripped path |
Authorization |
The client's bearer, verbatim. The gateway has already checked issuer, audience kiban-api and signature, but your service verifies it again |
x-correlation-id |
Set by the gateway: the client's value if it sent one, otherwise a generated id. Put it on your audit rows and your grants requests |
X-Forwarded-For |
The client address, appended to an upstream proxy's list when the gateway is configured to trust one |
Host |
The client's Host header, preserved |
x-user-* |
Never present. A client sending one gets 400 from the gateway. Your service must not read any identity header; the bearer is the only identity source |
Upstream timeouts are the gateway's: 2 seconds to dial, 5 seconds to the first response byte
(KIBAN_MODULE_DIAL_TIMEOUT, KIBAN_MODULE_RESPONSE_HEADER_TIMEOUT on the gateway); past
them the client gets 503 MODULE_UNAVAILABLE.
Environment. Compose sets these on every module service. Defaults are what the sample
main.go files use when a variable is absent (a host-run process).
| Variable | Compose value | Notes |
|---|---|---|
KIBAN_LISTEN_ADDR |
0.0.0.0 |
The host part of the listener only; the port is a constant in your main.go that must equal the manifest's service.port. Default 127.0.0.1 |
KIBAN_<KEY>_DB_HOST |
postgres |
Default 127.0.0.1 |
KIBAN_<KEY>_DB_PORT |
5432 |
Required |
KIBAN_<KEY>_DB_NAME |
${POSTGRES_DB} |
Required. One database for the whole platform; your module owns one schema in it |
KIBAN_<KEY>_DB_PASSWORD |
${KIBAN_<KEY>_DB_PASSWORD} from .env |
Required. The role name is not configurable: kiban_<key> |
KEYCLOAK_JWKS_URL |
http://keycloak:8080/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs |
Required |
KEYCLOAK_ISSUER_URL |
https://127.0.0.1:${KIBAN_GATEWAY_TLS_HOST_PORT:-8443}/realms/${KEYCLOAK_REALM} |
Required. Compared as a string with the token's iss, which is the public origin, not the container hostname |
KEYCLOAK_AUDIENCE |
not set | Default kiban-api |
KIBAN_AUTHZ_BASE_URL |
http://authz:8140 |
Default http://127.0.0.1:8140 |
KIBAN_ORG_BASE_URL |
http://org:8130 |
Default http://127.0.0.1:8130 |
Anything else is module-specific (the notification module also reads its SMTP host and port, a webhook HMAC secret and a delivery claim group) and is yours to add to the Compose block.
Listeners and probes. One process, one port, service.port from the manifest. Serve on it:
| Path | Who calls it | Response |
|---|---|---|
GET /health |
Compose healthchecks, operators | 200. This is service.healthPath in the manifest; the registry stores it, the gateway does not probe it |
GET /ready |
The Compose healthcheck (curl -sf http://127.0.0.1:<port>/ready) |
{ "data": { "status": "ok" } } after a database ping; 503 with INTERNAL_ERROR when the ping fails (modulekit's httpx.Ready does exactly this) |
GET /metrics |
The gateway's GET /api/platform/metrics, which scrapes every enabled module at http://<host>:<port>/metrics with a 2 second budget |
Prometheus text format |
/api/<key>/v1/... |
The gateway | Your API, every route behind your own bearer check and an authorization decision |
/health, /ready and /metrics are served at the root of your listener, not under
/api/<key>, and they are the only unauthenticated routes.
Token claims. modulekit.NewTokenVerifier(ctx, jwksURL, issuer, audience, "<key>") fetches
the JWKS once; Verify(ctx, token) parses the JWT against it, requires iss to equal
KEYCLOAK_ISSUER_URL and aud to contain KEYCLOAK_AUDIENCE, checks expiry and signature, and
returns the sub claim. azp is never accepted in place of aud. An unknown kid triggers one
JWKS refetch and one retry; every other failure is modulekit.ErrTokenInvalid. Run
RefreshPeriodically (10 minutes by default) so a rotated key is picked up. sub is the only
claim a module may use: it is the kcSub every internal endpoint takes. Roles or groups in the
token are never authority; authorization is always the decision below.
Manifest: module.manifest.json¶
Every key is required unless marked optional; the validator rejects unknown keys, except keys
starting with _, which are author comments. notification's file is the template.
| Key | Allowed values | Rule |
|---|---|---|
contractVersion |
"1" |
Any other value is refused |
moduleKey |
^[a-z][a-z0-9_]{1,31}$ |
Unique across the catalog. Names the directory, the schema, the role kiban_<key>, the feature-key prefix, the base path |
displayName |
string | Shown in the catalog |
version |
semver | What dependencies[].versionRange of other modules is checked against |
scopeType |
global, company |
Every shipped module is company |
mandatory |
bool | Copied into the catalog |
service.basePath |
exactly /api/<moduleKey> |
Also servers[0].url of the OpenAPI file |
service.port |
1..65535, unique across the catalog | The port the gateway dials. Shipped: 8150, 8160, 8170, 8180 |
service.healthPath |
an absolute path | Stored in the catalog |
service.image |
null |
Not read by anything in 0.1; the image comes from the Dockerfile stage named after the key |
dependencies[] |
{ "moduleKey", "versionRange", "required": true } |
Each names a module in the same validate batch (or the -catalog snapshot); versionRange is a semver range the target's version must satisfy; required must be true; no cycles. The gateway refuses your routes with MODULE_DEPENDENCY_MISSING while a dependency is not enabled |
org.requiredOrgUnitTypes |
list of type keys | ["company"] for every shipped module; copied into the catalog |
org.usesMembers, org.usesPositions |
bool | Declarative; not enforced |
data.postgresSchema |
exactly <moduleKey> |
The schema your migrations create |
data.migrationsPath |
"migrations" |
The directory the validator and tern read |
license.class |
foundation, open |
foundation requires license.spdx Apache-2.0; open requires Apache-2.0 or MIT |
license.spdx |
see above | The module's LICENSE file must match the canonical text for that id, whitespace aside |
license.entitlementRequired |
bool | Declarative; nothing checks entitlements in 0.1 |
events |
null |
Reserved |
Authorization fragment: authz.fragment.json¶
The top-level keys are moduleKey, roles, objects, relations, features, fieldCatalog,
fieldSets, rowScopes and bootstrapPolicy. moduleKey must equal the manifest's.
Unknown keys are refused; _-prefixed keys are comments.
What runs at runtime: the registry embeds the file and installs only its relations section
into the authorization engine, where it is merged with the base model. Everything else in the
file is validated for consistency with that model but is not loaded by any service in 0.1:
feature keys are enforced by the string your own handlers pass to the decision endpoint, and
the flags on roles[] drive no grant.
relations (optional; omit the key when every tier you need already exists on the base
model, as timesheet and helpdesk do). Object type, then relation name, then one expression:
| Form | Meaning |
|---|---|
{ "this": true } |
Direct tuples on this relation. false is refused |
{ "computedUserset": "<relation>" } |
Whoever holds <relation> on the same object. Must name a relation of the same type |
{ "tupleToUserset": { "tupleset": "<relation>", "computedUserset": "<relation>" } } |
Follow the tuples on tupleset (a relation of this type) to their subject objects and check computedUserset there. computedUserset must be defined on at least one type of the effective model (base plus your fragment) |
{ "union": [ ...expressions ] } |
Any of the children; at least one |
Any other construct (intersection, exclusion, wildcards, conditions) is outside the subset
and refused. Every type you declare must define "company_module": { "this": true }, the anchor
every object check binds through. A type may not redeclare a base type (user, system,
module, company, company_module, module_role_binding, access_bundle,
access_segment, member_directory, member, project_directory, project,
timesheet_entry, submission, position, group, document); it may reference their
relations, typically company_module's member, admin, editor, viewer, submitter and
approver, through a tupleToUserset over the anchor. A type name must be unique across the
catalog.
roles[], one entry per key:
| Key | Values | Rule |
|---|---|---|
roleKey |
string | Shipped modules use <key>_admin |
label |
string | |
scopeType |
global, company |
Validated |
grantsShellAccess |
bool | Declarative in 0.1 |
seedCompanyCreatorRole |
bool | Declarative in 0.1; see "Default grants" for what is actually written |
sortOrder |
int |
objects[], one entry per object type you want listed in a permission UI:
| Key | Values | Rule |
|---|---|---|
objectType |
a type declared in this fragment's relations |
A base type or an undeclared type is refused |
label |
string | |
scopeKind |
module, directory, object, document |
Validated |
parentKind |
an object type of the effective model | company_module for every shipped object |
managerRelation |
a relation of objectType |
The relation that may manage grants, by convention owner |
grantableRelations |
relations of objectType |
What a UI may offer to grant, e.g. ["owner", "editor", "viewer"] |
showInPermissionUi |
bool | |
sortOrder |
int |
features[], one entry per feature key your handlers check:
| Key | Values | Rule |
|---|---|---|
featureKey |
<moduleKey>.<scope>.<action> |
Must start with <moduleKey>.; unique across the catalog. <moduleKey>.access is reserved: the platform synthesizes it for every enabled module |
label |
string | |
scopeType |
global, company |
Validated |
requiresCompany |
bool | Declarative |
accessRuleKind |
relation, platform_role, module_eligibility |
Validated; every shipped feature is relation |
accessRulePayload |
{ "objectType", "relation" } or {} |
When both are set, relation must be defined on objectType in the effective model. {} means membership only, no relation leg |
sortOrder |
int | |
isSidebarEntry |
bool | Declarative; a shell may use it for navigation |
rowScopes[]: { "entityType": <a type this fragment declares>, "rules": [ ... ] };
entityType is validated, rules is not interpreted. fieldCatalog and fieldSets
are lists of objects, uninterpreted; ship []. bootstrapPolicy is
{ "seedCompanyCreatorObjectRelations": [] }, uninterpreted.
OpenAPI: openapi.yaml¶
A standard OpenAPI 3 document. The validator requires:
servers[0].urlequal toservice.basePath; the only other server allowed is/, for the probes. Path keys are relative to the base path: they start with/and never with/api/.- Every operation has a unique
operationId. - Every operation except
GET /healthandGET /readycarriesx-required-modules, a non-empty list of module keys, normally[<key>]. Each key named must be a module in the validate batch or the-catalogsnapshot. Nothing reads the annotation at runtime; it is the contract's statement of which modules a route needs, and the test suite validates your responses against the document.
Migrations¶
modules/<key>/migrations/ is a tern tree run as the database owner kiban; your service
connects as kiban_<key>, which has no DDL rights. tern.conf is the same for every module
except the schema name:
[database]
host = {{env "KIBAN_DB_HOST"}}
port = {{env "KIBAN_DB_PORT"}}
database = {{env "POSTGRES_DB"}}
user = kiban
password = {{env "KIBAN_DB_PASSWORD"}}
version_table = public.schema_version_<key>
sslmode = disable
Files are named NNNN_description.sql (four digits, lowercase, [a-z0-9_]), numbered without
gaps or duplicates, and each holds the up statements, the line
---- create above / drop below ----, and the down statements. 0001 creates the schema and
grants the runtime role its way in:
CREATE SCHEMA <key> AUTHORIZATION kiban;
GRANT USAGE ON SCHEMA <key> TO kiban_<key>;
GRANT SELECT ON public.schema_version_<key> TO kiban_<key>;
---- create above / drop below ----
REVOKE SELECT ON public.schema_version_<key> FROM kiban_<key>;
REVOKE USAGE ON SCHEMA <key> FROM kiban_<key>;
DROP SCHEMA <key> CASCADE;
The SELECT on the version table is what lets the service check at startup that its
migrations have been applied. Later files create tables under <key>. and grant kiban_<key>
table by table. Your audit table is audit.<key>__events with an append-only trigger calling
the shared audit.reject_mutation() and GRANT SELECT, INSERT to your role; do not redefine
that function (the four shipped modules did so before the rule existed and are grandfathered
by checksum).
The role file in the registry tree. kiban_<key> is created by the platform, not by you,
because role creation and the shared audit schema's grant list are outside a module's fence.
Add migrations/registry/00NN_<key>_role.sql (next free number) with, as 0005 and 0006 do
for notification:
CREATE ROLE kiban_<key> LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT;
GRANT CONNECT ON DATABASE kiban TO kiban_<key>;
GRANT USAGE ON SCHEMA audit TO kiban_<key>;
---- create above / drop below ----
REVOKE USAGE ON SCHEMA audit FROM kiban_<key>;
REVOKE CONNECT ON DATABASE kiban FROM kiban_<key>;
DROP ROLE kiban_<key>;
The role's password is set after the tree runs, from KIBAN_<KEY>_DB_PASSWORD, by
scripts/set-role-password.sh, both in infra/migrate-entrypoint.sh (Compose) and in the
Makefile's migrate-<key> target (host run).
The ledger. Every migrations directory carries checksums.json, file name to SHA-256. The
validator fails when a file is missing from it, a listed file is missing on disk, or a hash has
changed: a shipped migration is never edited, a new one is added. Record a new file with
go run ./cmd/modvalidate -write-checksums modules/<key>/
go run ./cmd/modvalidate -write-checksums migrations/registry
What a module migration may not do. The validator tokenizes each statement (strings,
comments, dollar quotes and quoted identifiers are real tokens, so nothing hides in them) and
applies a positive rule table. Allowed: CREATE, ALTER, DROP and COMMENT ON for schema,
extension, table, index, function, procedure, trigger, type, sequence, view, policy and domain;
GRANT and REVOKE with an ON clause; INSERT, UPDATE, DELETE, TRUNCATE; a WITH
query. Within those, the following are refused by name:
- A reference outside the fence: anything not in schema
<key>, notaudit.<key>__*, notpublic.schema_version_<key>.audit.reject_mutation()may be referenced as a trigger function but not created, replaced, altered or dropped. - An unqualified object name (
CREATE TABLE channellands onsearch_path);CREATE SCHEMAof anything but<key>(CREATE SCHEMA IF NOT EXISTS auditis tolerated as a no-op); a schema owned by anyone butkiban. CREATE EXTENSIONother than a barepgcrypto;SECURITY DEFINER; settingsearch_path;ALTER ... OWNER TO;ALTER ... SET SCHEMA.GRANT/REVOKEwithoutON(role membership);WITH GRANT OPTION; grants on a database, tablespace, language, type, domain, parameter or foreign object; grants on a schema other than<key>oraudit; grants to, or revokes from, any role butkiban_<key>.TRUNCATEoutside schema<key>(audit tables are append-only).- Any other statement:
DO,SET,CREATE ROLE,VACUUM,COPYand so on.
The registry tree is checked for layout and ledger only; its files legitimately create roles and grant on shared schemas.
Default grants¶
Nothing in the fragment grants anyone anything. When a module becomes installed and enabled
(registry start with the key in KIBAN_INSTALLED_MODULES, or POST
/api/platform/admin/modules/<key>/enable), the registry writes, for every active company, two
tuples on the object company_module:<companyId>/<key> through the authorization service's
audited store: the anchor #company @ company:<companyId>, which is what lets a company's
members resolve member and its administrators resolve admin on your module, and
#system @ system:platform, which is what lets a superadmin resolve admin. The org service
writes the same pair for every enabled module when a company is created, in the company's own
transaction. The anchor is ensured on every pass; the system tuple is written once per
(company, module) and never again, so an operator who deletes it to exclude superadmins from a
module stays in control. Disabling a module removes nothing. roles[].seedCompanyCreatorRole
and bootstrapPolicy are not consulted; a creator role for a module is something your module
grants itself, through the grants endpoint, when it decides to.
Internal endpoints¶
The three calls a module makes. The walk-through in Integrate your app shows each in context; here is every field. All three are reachable only inside the Compose network, which is one reason a module container must never publish a host port.
POST /internal/authz/effective-access/can on KIBAN_AUTHZ_BASE_URL, with the user's
bearer. The subject is always the bearer's sub.
| Request field | Type | Meaning |
|---|---|---|
featureKey |
string | Recorded as evidence and in the denial audit row. Use your fragment's key |
moduleKey |
string | Your key. Step 6 requires this module to be installed and enabled |
scope |
"company" or "global" |
global skips the company steps and needs requiredPlatformRole |
companyId |
uuid | Required for company scope; the company from your URL |
object |
{ "type", "id" } |
Optional. With relation, the object of the relation check. company_module ids are <companyId>/<moduleKey> |
relation |
string | Optional. Empty means no relation check: the decision stops after membership and module state |
requiredPlatformRole |
string | Optional. The platform role for a global check, or the operator exception below |
allowPlatformOperatorCompanyScope |
bool | Optional. Lets a holder of requiredPlatformRole pass the membership step without being a member; the company must still be active |
requiredCompanyRole |
string | Optional. A company role checked after membership |
actorId |
string | Must be absent or equal the bearer's sub; anything else is 400 |
requiresEligibility |
bool | Must be absent or false; true is 400 in this deployment |
correlationId |
string | Forward your request's x-correlation-id |
The steps, in order: subject exists; Keycloak account enabled; lifecycle active; for global
scope, platform role held; for company scope, company active, then membership (or the operator
exception), then requiredCompanyRole; module enabled; relation check when relation is set.
An object check is bound to companyId: a company_module object must be that company's, and a
module object must be anchored to that company's module or the answer is a denial.
| Response | Shape |
|---|---|
200 |
{ "data": { "allowed": bool, "reason": "<reason>", "evidence": [ { "key", "value" } ] } } |
503 AUTHORIZATION_UNAVAILABLE |
A dependency could not answer; details carries dependency, step and evidence. Refuse the request, never fail open |
400 |
Malformed body, actorId mismatch, requiresEligibility, or global scope without requiredPlatformRole |
401 |
No or invalid bearer |
reason is one of ALLOWED, AUTH_USER_NOT_FOUND, KEYCLOAK_DISABLED,
USER_LIFECYCLE_DISABLED, COMPANY_INACTIVE, COMPANY_MEMBERSHIP_REQUIRED,
COMPANY_ACCESS_BLOCKED, MODULE_DISABLED (also when the module is not installed),
PLATFORM_ROLE_REQUIRED, COMPANY_ROLE_REQUIRED, ENGINE_DENIED,
BUSINESS_ELIGIBILITY_DENIED, DEPENDENCY_UNAVAILABLE.
modulekit.AuthzClient.Can(ctx, bearer, featureKey, companyID, relation) sends
scope: "company" with your key and, when relation is not empty, the object
company_module:<companyId>/<key>; DoCan sends any request.
POST /internal/authz/grants on KIBAN_AUTHZ_BASE_URL, with the user's bearer.
| Request field | Type | Meaning |
|---|---|---|
op |
"grant" or "revoke" |
|
companyId |
uuid | The company every tuple is bound to |
tuples[] |
list, non-empty | One relation tuple each |
tuples[].objectType, tuples[].objectId |
strings | A type your fragment declares, or company_module with id <companyId>/<key>. Never a base type |
tuples[].relation |
string | A relation of that type |
tuples[].subjectType, tuples[].subjectId |
strings | user and a kcSub; or position/group and its id with subjectRelation |
tuples[].subjectRelation |
string, optional | A userset subject: holder for a position, member for a group. Must be a relation of subjectType |
correlationId |
string | Forward your request's x-correlation-id |
Checks, all before any write: every object belongs to one module and that module is enabled;
the bearer passes the company-scope decision for (module, company) (a superadmin passes without
membership); each module object is anchored to company_module:<companyId>/<key>, either
already or by a company_module tuple in the same request (which is how a create writes the
anchor and the owner together); a tuple on company_module itself requires the bearer to hold
admin on it. The tuples and an audit row commit in one transaction.
| Response | Shape |
|---|---|
200 |
{ "data": { "status": "ok", "count": <tuples written> } } |
422 VALIDATION_FAILED |
An object type, anchor, company_module id or subjectRelation the rules above refuse |
403 AUTHORIZATION_DENIED |
The bearer failed the decision or lacks admin; details.reason says which |
503 AUTHORIZATION_UNAVAILABLE |
The decision or the anchor check could not run |
400 |
Bad JSON, bad op, empty tuples, companyId not a UUID |
modulekit.AuthzClient.AnchorTuple(objectType, objectID, companyID) builds the anchor and
GrantOrRevoke(ctx, bearer, companyID, op, tuples, correlationID) sends the request.
GET /internal/org/companies/{companyId}/members/by-kcsub/{kcSub} on
KIBAN_ORG_BASE_URL. No bearer: org's fact reads are open inside the network.
companyId |
uuid path segment; a non-UUID is 400 |
kcSub |
the token's sub |
200 |
{ "data": { "isMember": bool, "isActive": bool, "memberId": "<uuid>" or null } }. memberId is null when isMember is false; isActive is the member row's flag |
modulekit.OrgClient.MemberByKcSub(ctx, companyID, kcSub) returns
(memberID, isMember, isActive, err); GetMember(ctx, memberID) returns the row with its
linked user.
Validate¶
make validate-modules # every modules/*/
go run ./cmd/modvalidate modules/<key>/
make validate-migrations # the foundation trees, including migrations/registry
The validator loads the five artifacts plus frontend/frontend.manifest.json when present,
applies every rule above, then the cross-module rules: moduleKey, service.basePath,
service.port, frontend route ids, feature keys and object types are unique across the batch
(pass -catalog snapshot.json to include modules outside it), dependencies resolve and form no
cycle. A frontend manifest needs routeBase equal to /app/<key>, a semver sdkVersionRange,
unique route ids and paths, :param segments that are identifiers, and each route's
featureKey declared in the fragment or equal to <key>.access. make check runs all of it.
Adding a module to the platform¶
Every item is needed; the platform has no runtime registration in 0.1.
modules/<key>/: the artifacts above,artifacts.gowith//go:embed authz.fragment.jsonand//go:embed module.manifest.jsonexportingAuthzFragmentJSONandManifestJSON, andLICENSE.internal/registry/builtin.go: an entry inBuiltinModulesmirroring the manifest (ModuleKey,DisplayName,ScopeType,Mandatory,BasePath,HealthPath,Port,LicenseClass,ManifestVersionandRequiredOrgUnitTypesfrommustManifest,AuthzFragmentfrommustAuthzRelations), plus thevar <key>AuthzRelationsnext to the existing four. A key inKIBAN_INSTALLED_MODULESthat the list does not know stops the registry at startup.migrations/registry/00NN_<key>_role.sqlas above, thengo run ./cmd/modvalidate -write-checksums migrations/registry.infra/Dockerfile.service: ago build ... -o /out/<key> ./modules/<key>/service/cmdline in the builder stage and aFROM runtime-base AS <key>stage that copies the binary and sets it as the entrypoint.Makefile:migrate-<key>inMIGRATE_TARGETS,MIGRATE_DIR_<key> := modules/<key>/migrationsandMIGRATE_PW_<key> := ./scripts/set-role-password.sh kiban_<key> KIBAN_<KEY>_DB_PASSWORD.infra/migrate-entrypoint.sh: after the foundation trees,go tool tern migrate -m modules/<key>/migrations -c modules/<key>/migrations/tern.conffollowed by the sameset-role-password.shline..env.exampleand.env:KIBAN_<KEY>_DB_PASSWORD=;infra/compose.yaml: the same variable on themigrateservice's environment.infra/compose.yaml: a<key>service copied fromnotification's block (build target<key>, the environment table above, the/readyhealthcheck,depends_onpostgres,migrate,bootstrap,organdregistry), and<key>appended toKIBAN_INSTALLED_MODULESon theregistryservice.infra/compose.yaml,gatewayservice:KIBAN_MODULE_HOST_<KEY>: <key>and<key>underdepends_on. Without the variable the gateway dials127.0.0.1:<port>, which is not your container.make validate-modules,make validate-migrations, thenmake devto rebuild the images and start the stack. Enable the module for a company withPOST /api/platform/admin/modules/<key>/enableor the sample shell.
A module missing from KIBAN_INSTALLED_MODULES at the registry's first boot is not installed
later without a registry restart with the updated list.
The four sample modules are the worked examples. notification is the smallest complete one;
helpdesk shows position- and group-based assignment; docs (DocShare) shows per-object
sharing as live tuples.
Contributing¶
Gates are make check (format, vet, vulnerability check, unit tests, module and migration
validation, web checks, coverage ratchet over 40 scopes, licence checks, secret scan) and
make test (full tests including the OpenFGA differential harness). Both need the isolated test
stack: make test-stack-up first. Coverage minimums only ever rise. See CONTRIBUTING.md in
the repository.