# How Hister Enforces Multi-User Isolation: Architecture and Security Model

> Learn how Hister enforces multi-user isolation. It uses UserID filters on database queries to prevent data leakage and allows controlled admin impersonation. Discover its architecture and security.

- Repository: [Adam Tauber/hister](https://github.com/asciimoo/hister)
- Tags: architecture
- Published: 2026-09-01

---

**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](https://github.com/asciimoo/hister/blob/main/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`:

```go
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)`:

```go
    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](https://github.com/asciimoo/hister/blob/main/server/server.go#L10-L20)**. This function implements the core isolation logic while enabling administrative oversight.

### The targetUserID Function

```go
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](https://github.com/asciimoo/hister/blob/main/server/indexer/indexer.go#L715-L722)**, the `GetByURLAndUser` function demonstrates this pattern:

```go
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](https://github.com/asciimoo/hister/blob/main/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](https://github.com/asciimoo/hister/blob/main/server/model/version.go#L34-L40)**:

```go
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:

```go
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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`](https://github.com/asciimoo/hister/blob/main/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`](https://github.com/asciimoo/hister/blob/main/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.