How Hister Enforces Multi-User Isolation: Architecture and Security Model
Hister isolates user data by extracting a UserID from authenticated sessions or bearer tokens and enforcing mandatory WHERE user_id = ? filters on every database query, preventing any cross-user data leakage while allowing admin impersonation through controlled headers.
Hister is a self-hosted document archiving and search platform that supports multiple users on a single instance. Robust Hister multi-user isolation is achieved through a defense-in-depth strategy that binds every HTTP request to a specific user identity and propagates that identity through all data access layers.
Authentication and Request Context Population
Every request begins with identity validation in populateUserContext, located in server/server.go#L38-L66. This function attempts to authenticate the caller through two mechanisms.
Session Cookie Authentication
First, the server checks for a signed session cookie named hister:
func populateUserContext(c *webContext) {
session, err := sessionStore.Get(c.Request, storeName)
if err == nil {
if uid, ok := session.Values["user_id"].(uint); ok && uid > 0 {
u, _ := model.GetUserByID(uid)
c.UserID, c.Username, c.IsAdmin = u.ID, u.Username, u.IsAdmin
c.Authenticated = true
c.userRules, _ = u.ParseRules()
return
}
}
// ... token fallback
}
When a valid session is found, the function populates c.UserID with the authenticated user's primary key, which becomes the foundation for all subsequent isolation checks.
Bearer Token Fallback
If no valid session exists, the system falls back to bearer token authentication via requestAccessToken(c):
if tok := requestAccessToken(c); tok != "" {
if u, err := model.GetUserByToken(tok); err == nil {
c.UserID, c.Username, c.IsAdmin = u.ID, u.Username, u.IsAdmin
c.Authenticated = true
c.userRules, _ = u.ParseRules()
}
}
Both paths result in a fully populated webContext struct carrying the UserID, Username, and IsAdmin flags that travel with the request through all handlers.
Target User Resolution and Admin Impersonation
After authentication, handlers determine the effective user ID through targetUserID in server/server.go#L10-L20. This function implements the core isolation logic while enabling administrative oversight.
The targetUserID Function
func targetUserID(c *webContext) uint {
uid := c.UserID
if c.Config.App.UserHandling && c.IsAdmin {
if h := c.Request.Header.Get("X-Hister-Target-User-ID"); h != "" {
if parsed, err := strconv.ParseUint(h, 10, 64); err == nil {
uid = uint(parsed)
}
}
}
return uid
}
For regular users, this function always returns their own UserID, ensuring they can only access their own data. Administrators may optionally supply the X-Hister-Target-User-ID header to operate on behalf of another user, but the request still flows through the same filtered code paths.
Database-Level Filtering
The data layer enforces isolation through mandatory SQL filters. Every model—including documents, history items, and versions—includes a user_id column that must match the request's effective user ID.
CRUD Operations with User Scoping
In server/indexer/indexer.go#L715-L722, the GetByURLAndUser function demonstrates this pattern:
func (i *Indexer) GetByURLAndUser(url string, uid uint) *document.Document {
// SELECT ... FROM documents WHERE url = ? AND user_id = ?
}
Similarly, server/indexer/indexer.go#L967-L970 shows GetAddCountByURLAndUser applying the same constraint. Even update operations respect these boundaries, as seen in server/model/version.go#L34-L40:
func MoveDocumentVersions(url string, fromUserID, toUserID uint) error {
return DB.
Where("url = ? AND user_id = ?", url, fromUserID).
Update("user_id", toUserID).Error
}
Because every query includes the user_id predicate, users cannot retrieve or modify records belonging to others, even if they possess the correct document ID or URL.
Public vs. Private Document Handling
Hister distinguishes between private user data and global public archives through the submittedDocumentUserID function. When a request includes the X-Hister-Public: true header, the function returns 0 instead of the authenticated user's ID:
func submittedDocumentUserID(c *webContext) uint {
if publicDocumentRequested(c) {
return 0
}
return targetUserID(c)
}
Documents stored with user_id = 0 are accessible globally, while all other values enforce private ownership. This explicit flagging prevents accidental exposure of sensitive data.
Practical Implementation Examples
Authenticating with Session Cookies
Store credentials and maintain session state across requests:
curl -c cookies.txt -X POST https://hister.example.com/api/login \
-d '{"username":"alice","password":"secret"}'
curl -b cookies.txt https://hister.example.com/api/documents
# Returns only Alice's documents
Using Bearer Tokens
For API access or CLI tools, authenticate with a token:
export HISTER_TOKEN=$(hister token --user bob)
curl -H "Authorization: Bearer $HISTER_TOKEN" \
https://hister.example.com/api/documents
# Returns only Bob's documents
Admin Impersonation
Administrators can troubleshoot or migrate data by targeting specific users:
export ADMIN_TOKEN=$(hister token --admin)
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "X-Hister-Target-User-ID: 42" \
https://hister.example.com/api/documents
# Returns documents for user 42 only
Creating Public Documents
Explicitly flag documents as public using the dedicated header:
curl -X POST -H "Authorization: Bearer $HISTER_TOKEN" \
-H "X-Hister-Public: true" \
-d '{"url":"https://example.com","title":"Public Archive"}' \
https://hister.example.com/api/documents
Summary
- Request Context Binding: Every request is authenticated via
populateUserContextinserver/server.go, which extracts and validates theUserIDfrom sessions or tokens. - Target User Resolution: The
targetUserIDfunction enforces isolation by defaulting to the authenticated user while allowing admins to impersonate viaX-Hister-Target-User-ID. - SQL-Level Enforcement: All data access functions in
server/indexer/indexer.goandserver/model/include mandatoryWHERE user_id = ?clauses. - Explicit Public Data: The
X-Hister-Publicheader triggersuser_id = 0storage, separating private archives from global documents.
Frequently Asked Questions
How does Hister identify the current user for each request?
The system validates signed session cookies or bearer tokens in populateUserContext (server/server.go#L38-L66), then stores the resolved UserID in the request-scoped webContext struct. This ID propagates through all subsequent handler and database operations.
Can administrators bypass Hister multi-user isolation?
No. Administrators may use the X-Hister-Target-User-ID header to impersonate other users, but the request still executes with a specific UserID filter applied at the database layer. This allows operational support without exposing data to unintended recipients.
What prevents users from modifying the UserID parameter in requests?
Users cannot inject arbitrary UserID values because the effective ID is determined server-side by targetUserID based on validated session data or cryptographic tokens. The final SQL queries always include hardcoded user_id predicates that reference this server-side value, not client input.
How does Hister handle shared or public documents?
When the X-Hister-Public: true header is present, submittedDocumentUserID returns 0, causing the document to be stored without a specific owner. These records are excluded from personal document lists but remain accessible through global search, while all other data maintains strict per-user isolation.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →