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.

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 populateUserContext in server/server.go, which extracts and validates the UserID from sessions or tokens.
  • Target User Resolution: The targetUserID function enforces isolation by defaulting to the authenticated user while allowing admins to impersonate via X-Hister-Target-User-ID.
  • SQL-Level Enforcement: All data access functions in server/indexer/indexer.go and server/model/ include mandatory WHERE user_id = ? clauses.
  • Explicit Public Data: The X-Hister-Public header triggers user_id = 0 storage, 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →