Back to flin
flin

Download Grants and Access Keys

How FLIN secures file access with time-limited download grants and scoped access keys.

Thales & Claude | March 25, 2026 8 min flin
flinsecuritydownload-grantsaccess-keysfile-storage

Signed URLs solve one problem: time-limited access to a file. But real applications need more. A legal firm wants to share a contract with a client who can download it exactly three times. A school wants to distribute exam papers with a password that students receive in class. A SaaS platform wants to let users share files with external collaborators who do not have accounts.

Signed URLs cannot express "three downloads maximum." They cannot require a password. They cannot be revoked after creation. They are a blunt instrument for a nuanced problem.

FLIN's download grants system solves this. Grants are first-class objects that wrap file access with configurable restrictions: time limits, use counts, password protection, and revocation. Sessions 217 and 218 implemented the complete system in two focused sessions, bringing the FM-3 Storage Backends milestone to 100% completion.

The Grant Model

A download grant is a ticket that authorizes access to a specific file under specific conditions:

rustpub struct DownloadGrant {
    pub id: String,              // UUID v4 -- unguessable
    pub file_hash: String,       // Content-addressable hash
    pub file_extension: String,  // For Content-Type detection
    pub max_uses: Option<u32>,   // None = unlimited
    pub uses_remaining: u32,     // Decremented on each download
    pub expires_at: Option<u64>, // Unix timestamp, None = never
    pub password_hash: Option<String>, // Argon2id hash
    pub created_at: u64,
    pub revoked: bool,
}

The ID is a UUID v4 -- 122 bits of randomness. An attacker cannot guess a valid grant ID. The file hash links the grant to a specific blob in content-addressable storage. The restrictions (max uses, expiration, password) are all optional and composable.

Creating Grants in FLIN

From the FLIN developer's perspective, grants are created with a single function call:

flinentity Document {
    title: text
    file: file
    owner: User
}

// Share a file with restrictions
route POST "/documents/:id/share" {
    doc = Document.find(params.id)

    // Time-limited grant (24 hours)
    grant = file_grant(doc.file, { expires: 86400 })

    // Use-limited grant (5 downloads)
    grant = file_grant(doc.file, { max_uses: 5 })

    // Password-protected grant
    grant = file_grant(doc.file, { password: "exam2026" })

    // Combined restrictions
    grant = file_grant(doc.file, {
        max_uses: 10,
        expires: 86400,
        password: "confidential"
    })

    // Get the download URL
    url = grant_url(grant)

    respond { url: url, grant_id: grant.id }
}

The file_grant function creates a grant and returns a grant object. The grant_url function returns the download URL: /_flin/grants/{uuid}. The URL is the same regardless of the storage backend -- it always points to FLIN's built-in grant endpoint, which handles validation, file retrieval, and use tracking internally.

The Grant Manager

Behind the FLIN syntax, the GrantManager coordinates all grant operations:

rustpub struct GrantManager {
    grants: Arc<RwLock<HashMap<String, DownloadGrant>>>,
    file_grants: Arc<RwLock<HashMap<String, HashSet<String>>>>, // Reverse index
}

impl GrantManager {
    pub fn create_grant(
        &self,
        file_hash: &str,
        extension: &str,
        options: GrantOptions,
    ) -> Result<DownloadGrant, GrantError> {
        let grant = DownloadGrant {
            id: Uuid::new_v4().to_string(),
            file_hash: file_hash.to_string(),
            file_extension: extension.to_string(),
            max_uses: options.max_uses,
            uses_remaining: options.max_uses.unwrap_or(u32::MAX),
            expires_at: options.expires_at,
            password_hash: options.password.map(|p| argon2id_hash(&p)),
            created_at: now(),
            revoked: false,
        };

        // Add to primary index
        let mut grants = self.grants.write().unwrap();
        grants.insert(grant.id.clone(), grant.clone());

        // Add to reverse index (file -> grants)
        let mut file_grants = self.file_grants.write().unwrap();
        file_grants
            .entry(file_hash.to_string())
            .or_default()
            .insert(grant.id.clone());

        Ok(grant)
    }
}

Two data structures work together. The primary index maps grant IDs to grant objects for fast lookup during downloads. The reverse index maps file hashes to their associated grant IDs, enabling efficient per-file operations (list all grants for a file, revoke all grants for a file).

Both indexes use Arc<RwLock<...>> for thread safety. Read locks are shared (multiple downloads can validate grants simultaneously), and write locks are exclusive (grant creation and revocation are serialized). This matches the access pattern perfectly: downloads are frequent and concurrent; grant management is infrequent and can afford brief serialization.

The Download Flow

When a client hits /_flin/grants/{id}, the HTTP server executes a precise validation sequence:

1. Extract grant_id from URL path
2. Extract password from ?p= query parameter (if present)
3. Look up grant by ID
   -> 404 if not found
4. Check revocation
   -> 410 if revoked
5. Check expiration
   -> 410 if expired
6. Check remaining uses
   -> 410 if exhausted
7. Check password (if grant requires one)
   -> 401 if missing or invalid
8. Read file from storage backend
9. Consume one use (decrement uses_remaining)
10. Stream file with Content-Type and Content-Disposition headers

The order matters. Expiration is checked before uses, which is checked before password. This prevents an attacker from learning whether a grant exists by trying different passwords -- an expired grant returns 410 regardless of the password provided.

flin// The download endpoint from the client's perspective
// GET /_flin/grants/550e8400-e29b-41d4-a716-446655440000

// With password
// GET /_flin/grants/550e8400-e29b-41d4-a716-446655440000?p=exam2026

Password Protection

Grant passwords are hashed with Argon2id before storage. Argon2id is the current state-of-the-art password hashing algorithm, resistant to both GPU attacks and side-channel attacks:

rustfn verify_password(stored_hash: &str, provided: &str) -> bool {
    argon2id_verify(stored_hash, provided)
}

The password is never stored in plaintext. Even if the grant database is compromised, the passwords cannot be recovered. This matters for use cases like exam distribution, where the password has real-world significance beyond file access.

Per-File Grant Operations

Session 218 added a reverse index that tracks which grants belong to which files. This enables four per-file operations:

flin// List all active grants for a file
grants = file_list_grants(doc.file)
for grant in grants {
    print(grant.id, grant.uses_remaining)
}

// Revoke all grants for a file (e.g., when deleting a document)
count = file_revoke_all_grants(doc.file)
print("Revoked " + count + " grants")

// Check if a file has any active grants
if file_has_grants(doc.file) {
    print("File has active sharing links")
}

// Count active grants
count = file_grant_count(doc.file)

The reverse index makes these operations O(1) for lookup and O(n) for iteration, where n is the number of grants for that specific file -- not the total number of grants in the system. Without the reverse index, listing grants for a file would require scanning every grant in the system.

Grant Cleanup Scheduler

Grants accumulate over time. Expired grants and exhausted grants serve no purpose but consume memory. Session 218 added an automatic cleanup scheduler:

rustpub fn start_cleanup_scheduler(
    grant_manager: Arc<GrantManager>,
    interval_secs: u64,
) -> JoinHandle<()> {
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(
            Duration::from_secs(interval_secs)
        );
        loop {
            interval.tick().await;
            let cleaned = grant_manager.cleanup_expired();
            if cleaned > 0 {
                debug!("Grant cleanup: removed {} expired/exhausted grants", cleaned);
            }
        }
    })
}

The scheduler runs as a background Tokio task, cleaning up dead grants every 5 minutes by default. The interval is configurable via the FLIN_GRANT_CLEANUP_INTERVAL environment variable. The cleanup is idempotent and thread-safe -- it acquires a write lock, removes expired and exhausted grants from both indexes, and releases the lock.

The cleanup method also maintains consistency between the two indexes. When a grant is removed from the primary index, its ID is also removed from the reverse index. If removing a grant leaves a file's grant set empty, the empty set is removed from the reverse index to prevent memory leaks.

The Complete Access Control Stack

With download grants complete, FLIN offers three levels of file access control:

LevelMechanismUse Case
Public URLfile.urlProfile pictures, public assets
Signed URLfile_signed_url(file, duration)Time-limited access, CDN-compatible
Download Grantfile_grant(file, options)Use limits, passwords, revocation

These three levels compose naturally. A public-facing gallery uses public URLs. An internal document system uses signed URLs. A file-sharing feature uses download grants. The FLIN developer chooses the appropriate level for each use case without building custom access control infrastructure.

Test Coverage

The grant system is one of the most thoroughly tested components in FLIN:

ComponentTests
Grant creation (basic, options)4
Grant validation (success, failure)3
Use consumption and exhaustion2
Password (required, invalid, valid)3
Revocation1
Cleanup (expired, exhausted)3
Per-file operations12
Cleanup scheduler2
Total34

Every error path is tested. Every edge case -- expired grant, exhausted uses, wrong password, revoked grant, missing grant -- has an explicit test that verifies the correct error type and HTTP status code.

By the end of Session 218, the FM-3 Storage Backends milestone was 100% complete: 16 out of 16 tasks done, 3,029 tests passing. The storage layer was production-ready, with four backends, signed URLs, download grants, per-file access tracking, and automatic cleanup. The foundation was solid enough to build the rest of FLIN's file intelligence stack on top of it.

In the next article, we shift from storing files to understanding them. Text chunking is the bridge between raw documents and semantic search -- the algorithm that splits a 50-page PDF into pieces small enough for an embedding model to process.


This is Part 129 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.

Series Navigation: - [128] R2 and Google Cloud Storage Backends - [129] Download Grants and Access Keys (you are here) - [130] Text Chunking Strategies

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles