Security Model and Hardening

Adapted from docs/admin/Security.md in the Wikantik repository; current as of release 2.4.58.

This page is for administrators who deploy and operate Wikantik. It describes how users authenticate, how access is authorised, which protections are on by default, and which settings can weaken them. It states what the code does today; each section names the class or property that enforces it. To report a vulnerability, see the security policy (SECURITY.md) instead. Deep references are linked from each section.

Authenticate users

Wikantik authenticates through JAAS. The default login module is com.wikantik.auth.login.UserDatabaseLoginModule (wikantik.loginModule.class), which checks the supplied password against the PostgreSQL-backed user database (see PostgreSQL guide). New passwords are hashed with bcrypt.

MethodHow it worksReference
Database accounts (default)UserDatabaseLoginModule against the users table; the React login page posts credentialsPostgreSQL guide
HTTP BasicBasicAuthFilter turns an Authorization: Basic header on /api/* and /admin/* into a real login, for scripts and CIthis page
Servlet-container authcredentials supplied by the container (getUserPrincipal) are always picked up; to make users log in through the container, add your own <security-constraint> (with an <auth-constraint> role), <login-config> and <security-role> elements to web.xml. The shipped file has no commented-out block to enable: its only <security-constraint> is the TLS transport-guarantee (NONE; set CONFIDENTIAL for production)wikantik.properties (authentication section)
Single sign-onOpenID Connect and SAML 2.0 through pac4j, alongside local loginSingle Sign-On guide
IdP provisioningSCIM 2.0 server at /scim/v2/* for onboarding and offboardingSCIM Provisioning guide
Custom JAAS moduleset wikantik.loginModule.class to your own LoginModule (zero-argument constructor); options go in wikantik.loginModule.options.*wikantik.properties

Wikantik ships no LDAP login module. To authenticate against LDAP, supply a JAAS LoginModule through wikantik.loginModule.class, or put an LDAP-capable IdP in front of Wikantik and use SSO.

SSO identity binding

SSO links a login to a local account through wikantik.sso.identityClaim (default sub). Set it to preferred_username only if your IdP keeps that claim stable and unique, because it is mutable at most IdPs. SSO never adopts a pre-existing local account of the same name that was not created by SSO; a name collision without a matching marker fails closed. Configuration detail is in Single Sign-On guide.

Password policy

PasswordValidator follows NIST 800-63B: length over composition rules, plus a blocklist.

PropertyDefaultEffect
wikantik.password.minLength8shortest accepted password
wikantik.password.maxLength128longest accepted password
wikantik.password.blocklist.enabledtruereject passwords on the bundled common-password list (case-insensitive)
wikantik.login.throttlingtrueslow repeated failed logins per account

Deactivating an account locks it out of password and remember-me login.

Authorise access

Authorisation combines three layers: policy grants for what a role may do, groups for who holds the role, and page ACLs that narrow access to individual pages.

Policy grants

Default permissions for each role are rows in the policy_grants table (principal_type, principal_name, permission_type, target, actions). Manage them at Admin → Security (/admin/security), backed by /admin/policy. The migration V003__policy_grants.sql seeds the baseline:

RoleSeeded permissions
Allview any page; edit own preferences and profile; log in
Authenticatedmodify and rename pages; create pages and groups; export the vault (V060__wiki_export_permission.sql added export); view groups; edit groups they belong to
Assertedview groups
AdminAllPermission (V043__admin_allpermission_canonical.sql collapsed the older page and wiki wildcard rows into a single all row)

Permission types are page, wiki, group, admin and all. Page permissions are view, comment, edit, modify, upload, rename, delete; wiki permissions are createPages, createGroups, export, editPreferences, editProfile, login. Always change grants through the admin UI or /admin/policy, because the policy is cached and only the API refreshes it. The file-based WEB-INF/wikantik.policy applies only when no wikantik.datasource is configured; if a datasource is configured but the database policy cannot be initialised, startup fails with a WikiException rather than falling back to the file.

Two rules in DatabasePolicy matter when you edit grants:

Who counts as an administrator

/admin/* is protected by AdminAuthFilter. A request passes when the session holds AllPermission or a scoped admin grant for the area it targets. AllPermission comes from the Admin group: membership of the Admin group (the groups and group_members tables) is what makes an account an administrator. A row in the roles table alone is not enough: such an account authenticates but receives HTTP 403 on /admin/*. The tell is that a wrong password returns 401 and a correct one returns 403.

Add an administrator through the API so the in-memory group cache updates. PUT replaces the member list, so read it first:

curl -u admin:... http://localhost:8080/admin/groups/Admin
curl -u admin:... -X PUT -H 'Content-Type: application/json' \
     -d '{"members":["existing1","existing2","new-admin"]}' \
     http://localhost:8080/admin/groups/Admin

Whatever can write the Admin group can create administrators: the group editor and PUT /admin/groups/Admin above. SCIM cannot — it refuses every write to the Admin group with 403 (see SCIM Provisioning guide). Releases up to and including 2.4.56 lacked that guard, so on those versions the SCIM bearer token is an administrator-equivalent credential.

Other behaviour of AdminAuthFilter:

Scoped admin access

/admin/* spans many functional areas behind one AllPermission check, so any credential that reaches one reaches all of them, including apikeys, policy and connector-credentials. To grant one area only, create a policy grant with permission type admin, target set to the first path segment after /admin/, and action access:

{"principalType":"user","principalName":"shipper-account","permissionType":"admin",
 "target":"insights","actions":"access"}

Post it to /admin/policy (not raw SQL). AdminAuthFilter derives the area from the request path, so a new endpoint gets its own area that nobody holds a grant for and keeps requiring AllPermission. A bare /admin or a segment containing .. or : never matches an area grant. AllPermission implies every area grant, so existing administrators are unaffected. The content-intelligence shipper uses this; see Content Intelligence guide.

Page ACLs

A page can restrict itself with inline ACL markup in its body:

[{ALLOW view Admin}]()
[{ALLOW edit Admin,Editors}]()

Principals after the permission are comma-separated. Pages shipped in wikantik-wikipages write the directive with a trailing (), for example [{ALLOW edit Admin}](); the ACL itself is read from the [{ALLOW ...}]() part (DefaultAclManager.ACL_REGEX).

When a page carries an ACL, a user needs both the matching role or group grant and an ACL entry that names one of their principals. REST endpoints under /api/* enforce ACLs through RestServletBase.checkPagePermission(), and /wiki/{slug}?format=md|json hides restricted pages with a 404.

The directives name people by login, so their text is visible only to those who may change the page. The page API (GET /api/pages/{name}, old versions included), ?format=md|json, the diff endpoint, the Obsidian export and /tools/get_page remove every [{ALLOW ...}]() and [{DENY ...}]() directive from the text they return to a caller without edit on the page. The agent projection (/api/pages/for-agent/{id}, get_page_for_agent), read_pages on the knowledge MCP and briefings never return them to anyone. Chunking and the Lucene content field never contain them either, so search results, bundles and retrieval never quote them. Editors and administrators see the directives as written in the page API and the editor, and ACL evaluation is unchanged. Name groups rather than people in ACLs where you can: a login named in an ACL is still visible to everyone who can edit that page.

Use login names in groups and ACLs

Identify people by login name everywhere access is decided.

Who can see login names

People are shown to each other by display name. A login name is private: with Google sign-in it is the user's email address. Only three things see one: the account's owner (on the Preferences page), administrators on /admin/*, and the admin MCP server, which is administrator-equivalent. The one exception is a login written into a page's access rules: anyone who can edit that page sees those lines (see Page ACLs), which is why you should name groups there instead of people.

Page ownership and the audit trail

Page ownership is covered in Page Ownership guide. Authentication, authorisation, content and admin events go to a tamper-evident hash-chained log; see Audit Log guide and Admin → Audit (/admin/audit).

Upgrading to 2.4.53: authorization checks

Release 2.4.53 matches group members, user policy grants and ACL names by login name. Run these read-only queries against the wiki database before you deploy, so that nothing silently stops matching. The upgrade changes no data.

Group members that are not login names. Fix each by replacing the member with the login name, using the group editor or PUT /admin/groups/{name}. Check the Admin group first: an administrator listed by full name no longer counts as a member after the upgrade. After the upgrade, startup logs one WARN (logger GroupMemberLoginCheck) listing such members, and an ERROR naming any in the Admin group, until they are fixed.

SELECT gm.name, gm.member
  FROM group_members gm
 WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.login_name = gm.member)
 ORDER BY gm.name, gm.member;

-- the login name each one should become
SELECT gm.name, gm.member, u.login_name
  FROM group_members gm
  JOIN users u ON gm.member IN (u.full_name, u.wiki_name)
 WHERE gm.member <> u.login_name;

User-typed policy grants whose principal is not a login name. Such a row grants nothing after the upgrade; re-create it with the login name at /admin/security. The type is read trimmed and case-insensitively; rows with any type other than role, group or user are skipped with a warning, so look for those too.

SELECT p.*
  FROM policy_grants p
 WHERE lower(trim(p.principal_type)) = 'user'
   AND NOT EXISTS (SELECT 1 FROM users u WHERE u.login_name = p.principal_name);

SELECT * FROM policy_grants
 WHERE lower(trim(principal_type)) NOT IN ('role', 'group', 'user');

Role- or group-typed grants that name a user. Before 2.4.53 a grant matched on the principal name alone, so a role or group row naming a user's login, full name or wiki name granted that user. After the upgrade such a row matches only a role or group of that name, so the user loses the permission. /admin/security offers role as the default type, so check for these rows. Change each one to type user with the login name shown in grant_to_login:

SELECT p.id, p.principal_type, p.principal_name, p.permission_type, p.target, p.actions,
       u.login_name AS grant_to_login
  FROM policy_grants p
  JOIN users u ON p.principal_name IN (u.login_name, u.full_name, u.wiki_name)
 WHERE lower(trim(p.principal_type)) IN ('role', 'group')
   AND p.principal_name NOT IN ('All', 'Anonymous', 'Asserted', 'Authenticated')
   AND NOT EXISTS (SELECT 1 FROM groups g WHERE g.name = p.principal_name)
 ORDER BY p.id;

The query cannot see roles that the web container grants (the <security-role> names in web.xml, used with container authentication). If a reported row names such a role, it still works for members of that role; leave it, and add a separate user row only if the named user relied on it.

Display-name collisions. These accounts keep working, and an ACL or session no longer resolves through the colliding name. Renaming an account whose full or wiki name equals another account's login removes the ambiguity. In 2.4.53 a shared full name was allowed and needed no change, but an ACL that names it by full name is ambiguous, so use login names there. (Since 2.4.58 display names are unique: a shared one is refused on save, and bin/db/one-shots/dedupe_display_names.sh renames the existing duplicates once; see Use login names in groups and ACLs.)

-- a full or wiki name that equals another account's login name
SELECT a.login_name, a.full_name, a.wiki_name, b.login_name AS shadowed_login
  FROM users a
  JOIN users b ON b.login_name IN (a.full_name, a.wiki_name) AND a.login_name <> b.login_name;

-- accounts that share a full name
SELECT full_name, array_agg(login_name)
  FROM users
 WHERE full_name IS NOT NULL AND trim(full_name) <> ''
 GROUP BY full_name HAVING count(*) > 1;

-- full or wiki names equal (ignoring case) to a built-in role, a group, or a role/group grant
SELECT u.login_name, u.full_name, u.wiki_name
  FROM users u
 WHERE EXISTS (SELECT 1
                 FROM (VALUES ('admin'), ('all'), ('anonymous'), ('asserted'), ('authenticated')
                       UNION SELECT lower(name) FROM groups
                       UNION SELECT lower(principal_name) FROM policy_grants
                              WHERE lower(trim(principal_type)) IN ('role', 'group')) AS r(n)
                WHERE r.n IN (lower(trim(u.full_name)), lower(trim(u.wiki_name))));

Page ACLs that name people by display name. ACLs live in page bodies, so no query finds them. After the upgrade an ACL user entry grants only when it identifies exactly one account, and then only that account's login: an entry naming a full or wiki name that several accounts share stops granting, and an entry naming a name no account holds matches only a container-authenticated login of exactly that name. Search your page store for [{ALLOW and replace display names with login names:

grep -rn --include='*.md' '\[{ALLOW' /path/to/page-store

Also review active mcp, mcp_admin and all API keys that users created for themselves, because non-administrators can no longer mint them (see API Keys guide). Revoke any you do not want through /admin/apikeys:

SELECT k.id, k.principal_login, k.label, k.scope, k.created_at, k.last_used_at
  FROM api_keys k
 WHERE k.revoked_at IS NULL
   AND k.scope IN ('mcp', 'mcp_admin', 'all')
   AND k.created_by = k.principal_login
   AND NOT EXISTS (SELECT 1 FROM group_members gm
                    WHERE gm.name = 'Admin' AND gm.member = k.principal_login)
 ORDER BY k.created_at;

This last query does not see administrators whose rights come from a custom AllPermission policy grant to some other group, so compare it with /admin/security.

Bootstrap admin override

wikantik.admin.bootstrap names a login that gets AllPermission regardless of database grants. It exists only to recover access to a fresh or locked-out installation. The override applies only to a session that authenticated with that login name. An asserted ("remembered name") identity never qualifies, however it was obtained.

PropertyDefaultMeaning
wikantik.admin.bootstrapblanklogin name of the bootstrap admin; blank means no override
wikantik.admin.bootstrap.maxAgeSecondsblank (86400, 24 hours)how long the override lasts after server start

While it is active, startup logs a CRITICAL: BOOTSTRAP ADMIN OVERRIDE IS ACTIVE error with the expiry. Remove the property once a real administrator works; do not leave it set in production.

Asserted identities

With wikantik.cookieAssertions (default true), a visitor who has not logged in can "assert" a name through a cookie. The session then gets the Asserted role instead of Authenticated. This is the old wiki convenience of remembering who you say you are; it is not authentication and the property file itself calls it unsafe. An asserted session holds only what the Asserted and All policy grants give it. By default that is viewing groups (Asserted) plus the All grants: view any page, editPreferences, editProfile and login (V003__policy_grants.sql). It never receives authenticated rights such as the bootstrap override. Set wikantik.cookieAssertions=false if you do not want the feature.

Control agent and API access

API keys and agent access

API keys are SHA-256 hashed; only the hash is stored and the plaintext is shown once. ApiKeyService.Scope has four values:

ScopeWire valueReaches
MCP_READmcp_read/knowledge-mcp only (read-only)
MCP_ADMINmcp_admin/wikantik-admin-mcp and /knowledge-mcp (full admin)
TOOLStools/tools/* only
ALLallevery key-protected surface; the default when an administrator's POST omits scope (a non-admin self-service POST defaults to mcp_read)

The MCP scopes are a rank hierarchy (mcp_read is contained in mcp_admin); tools is separate. Before 2.4.54 the full-admin scope was named mcp; that name is still accepted as an alias and keys stored under it remain full-admin (see Legacy mcp scope name). Prefer the narrowest scope. Non-administrators can self-mint only tools and mcp_read keys; mcp_admin and all keys are issued by an administrator at /admin/apikeys. The /api/* REST surface uses session or Basic authentication and does not accept API keys.

/knowledge-mcp and the other MCP and tools endpoints also fail closed. If there is no DB-backed API key service (no datasource), no CIDR allowlist (mcp.access.allowedCidrs, tools.access.allowedCidrs) and allowUnrestricted is not true, they answer 503 to everything. Otherwise a request needs a key whose scope covers the endpoint (a key with the wrong scope, or no valid key, gets 403) or a source address inside the allowlist. Each has its own per-client limiter (mcp.ratelimit.*, tools.ratelimit.*). Details: API Keys guide, MCP integration guide.

Rate limiting

RateLimitFilter limits requests per client IP on a sliding window. It runs on /api/*, /id/*, /export/* and /sparql, after request metrics and before the authentication filters. It has two tiers:

TierPathsPer-client limitGlobal limit
defaulteverything the filter maps25 requests per secondnone (concurrency is bounded by BackpressureFilter)
expensiveprefixes /api/bundle, /api/search, /sparql3 requests per second10 requests per second across all clients

Exempt from limiting: loopback callers (IPv4 and IPv6), any IPv4 CIDR in the exempt list, and the exact path /api/health. The client IP is getRemoteAddr(), which is the real client only if Tomcat's RemoteIpValve is configured to read the header your proxy sets: CF-Connecting-IP behind Cloudflare, the deploy-local template hard-codes it, and Docker deployments set it with PROXY_REMOTE_IP_HEADER (see Docker deployment guide). A rejected request gets 429 with Retry-After: 1, a SecurityLog line, and an increment of wikantik_ratelimit.rejected_total{tier=...}.

Configure it with environment variables, read once at startup. A limit of 0 disables that bucket and all zeros disable the filter:

VariableDefault
WIKANTIK_RATELIMIT_DEFAULT_PERCLIENT25
WIKANTIK_RATELIMIT_EXPENSIVE_PERCLIENT3
WIKANTIK_RATELIMIT_EXPENSIVE_GLOBAL10
WIKANTIK_RATELIMIT_EXPENSIVE_PATHS/api/bundle,/api/search,/sparql
WIKANTIK_RATELIMIT_EXEMPT_CIDRSempty

Get the proxy header right. If the configured header does not match what your proxy sends, every request appears to come from the proxy's own loopback or private address. If the proxy is on loopback, loopback is exempt from rate limiting, so nobody is limited. If the proxy is on a private but non-loopback address, every client shares one rate-limit bucket. Either way, InternalNetworkFilter would let anyone reach /metrics and /api/health, and any mcp.access.allowedCidrs or tools.access.allowedCidrs entry covering that address would trust every caller. The reverse is just as dangerous: a proxy that forwards a client-supplied copy of the trusted header lets clients spoof 127.0.0.1. Make the proxy overwrite the header, and check the result after any proxy change.

Public ontology surfaces

/sparql, /id/{type}/{id} and /export/* are public, read-only and permissive about cross-origin access. They are safe to leave public because of a public/restricted split: PublicProjectionFilter keeps pages, entities and edges that an anonymous guest cannot view out of the materialised dataset entirely, so restricted content cannot be queried. /sparql rejects update operations and caps results and run time. A page's dct:creator comes from its author: frontmatter line: a value that is an account's login is published as that account's display name, never as the login. See Ontology Management guide.

Reduce risk from risky features

Connector egress guard

Connector HTTP fetches (web crawler, sitemap, feed, GitHub, Confluence) pass through EgressGuard, a server-side-request-forgery control. It rejects any URL whose scheme is not http or https, and any host that resolves to a loopback, private, link-local (including the cloud metadata address 169.254.169.254), multicast or unspecified address, or an IPv6 unique-local address. It re-validates every redirect hop, up to 5. To crawl an internal host on purpose, start the JVM with -Dwikantik.connectors.egress.allowPrivate=true; the setting is read only as a system property, and the scheme check still applies. The filesystem connector type is properties-only and cannot be created from the admin UI. See Connectors guide.

JDBCPlugin

JDBCPlugin runs SQL authored in a page against the wiki's own datasource, using the application's database role. It is off by default behind wikantik.plugin.jdbc.enabled (false), checked before anything else. The additional admin check applies to the user viewing the page, not to the author, so the real risk is an editor planting SQL that runs, with the application's database role, when an administrator views the page. Enable it only if you accept that a high-privilege database role executes editor-supplied SQL.

Render-cache isolation

A render that depends on the viewer (an ACL-aware include, a $username variable) flags itself with Context.VAR_VIEWER_SENSITIVE. Those renders are excluded from the principal-less document and HTML caches, so output rendered for one user is never served to another.

Other defaults

Where to go next


Cluster Hub: Wikantik Platform Development Hub: Architecture, MCP Protocols, and Agent Briefings (wikantik-development)