Chapter 4: Authorization with change_marker() — Compile-Time Authorization Proofs
“The compiler can mathematically prove that authorization happened first.”
In Chapter 3, you learned how markers prevent domain mix-ups. Now you’ll learn how to encode authorization in markers using change_marker(), so the compiler can mathematically prove that authorization checks weren’t forgotten.
The Authorization Problem
Markers prevent domain confusion. But what about permissions? How do we encode “this user is authorized to write to this directory”?
Traditional Approach: Runtime Checks Everywhere
#![allow(unused)]
fn main() {
use strict_path::StrictPath;
struct UserFiles;
// ❌ Problem: Authorization check inside every operation
fn write_user_file(path: &StrictPath<UserFiles>, user_id: &str, data: &[u8])
-> std::io::Result<()>
{
if !is_authorized(user_id) { // Runtime check (can forget!)
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Unauthorized"
));
}
path.write(data)
}
fn delete_user_file(path: &StrictPath<UserFiles>, user_id: &str)
-> std::io::Result<()>
{
if !is_authorized(user_id) { // Repeated check (can forget!)
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Unauthorized"
));
}
path.remove_file()
}
fn read_user_file(path: &StrictPath<UserFiles>, user_id: &str)
-> std::io::Result<Vec<u8>>
{
// Oops! Forgot the authorization check here! 🚨
path.read()
}
fn is_authorized(user_id: &str) -> bool {
user_id == "alice"
}
}
Problems:
- ❌ Authorization checks scattered everywhere
- ❌ Easy to forget a check (see
read_user_file) - ❌ No compile-time guarantee that authorization happened
- ❌ Code review has to catch missing checks (humans are fallible)
Better Approach: Encode Authorization in the Type
Instead of checking authorization repeatedly, we encode it in the type once:
#![allow(unused)]
fn main() {
use strict_path::StrictPath;
// Resource marker: describes WHAT directory
struct UserFiles;
// Permission markers: describe LEVEL of access
struct ReadOnly;
struct ReadWrite;
// Authorization gate: validates token → returns authorized marker
fn authenticate_user_access(
token: &str,
path: StrictPath<(UserFiles, ReadOnly)>
) -> Option<StrictPath<(UserFiles, ReadWrite)>> {
// ✅ Authorization: Token validated (checked once here!)
if validate_token(token) {
// Transform marker to encode proven authorization
Some(path.change_marker::<(UserFiles, ReadWrite)>())
} else {
None
}
}
fn validate_token(token: &str) -> bool {
token == "valid-token-12345" // Real apps: JWT validation, database lookup, etc.
}
// Functions accept paths that already prove authorization
fn write_user_file(path: &StrictPath<(UserFiles, ReadWrite)>, data: &[u8])
-> std::io::Result<()>
{
// No authorization check needed! Type proves it already happened.
path.write(data)
}
fn delete_user_file(path: &StrictPath<(UserFiles, ReadWrite)>)
-> std::io::Result<()>
{
// No authorization check needed! Type proves it already happened.
path.remove_file()
}
fn read_user_file(path: &StrictPath<(UserFiles, ReadOnly)>)
-> std::io::Result<Vec<u8>>
{
// ReadOnly access is sufficient for reading
path.read()
}
}
Understanding change_marker()
What change_marker() Is NOT
#![allow(unused)]
fn main() {
// ❌ WRONG way to think about it:
// "change_marker() grants permissions"
// "change_marker() does authorization"
}
What change_marker() Actually Does
#![allow(unused)]
fn main() {
// ✅ RIGHT way to think about it:
// "change_marker() ENCODES proven authorization in the type"
// "change_marker() transforms the marker AFTER authorization passed"
}
The pattern:
- ✅ Check authorization (token validation, capability check, etc.)
- ✅ If authorized: call
change_marker()to encode that fact in the type - ✅ Pass the new type to functions that require authorization
- ✅ The compiler proves authorization happened (can’t get the marker any other way!)
Using It: Complete Example
use strict_path::StrictPath;
struct UserFiles;
struct ReadOnly;
struct ReadWrite;
fn handle_request(token: &str, filename: &str, data: Option<&[u8]>)
-> Result<(), Box<dyn std::error::Error>>
{
// Start with read-only access (no authorization yet)
let user_files_dir: StrictPath<(UserFiles, ReadOnly)> =
StrictPath::with_boundary_create("./data/user_files")?;
let file_path = user_files_dir.strict_join(filename)?;
// Anyone can read with ReadOnly marker
let _content = read_user_file(&file_path)?;
println!("✅ Read succeeded (no authorization needed)");
// Try to upgrade to ReadWrite by authenticating
if let Some(writable_path) = authenticate_user_access(token, file_path) {
// ✅ Token validated! Now we have ReadWrite access
println!("✅ Authorization succeeded");
if let Some(data) = data {
write_user_file(&writable_path, data)?;
println!("✅ Write succeeded (authorization proven by type)");
}
delete_user_file(&writable_path)?;
println!("✅ Delete succeeded (authorization proven by type)");
} else {
println!("❌ Authorization failed — cannot write or delete");
}
Ok(())
}
fn authenticate_user_access(
token: &str,
path: StrictPath<(UserFiles, ReadOnly)>
) -> Option<StrictPath<(UserFiles, ReadWrite)>> {
if validate_token(token) {
Some(path.change_marker())
} else {
None
}
}
fn validate_token(token: &str) -> bool {
token == "valid-token-12345"
}
fn read_user_file(path: &StrictPath<(UserFiles, ReadOnly)>) -> std::io::Result<Vec<u8>> {
path.read()
}
fn write_user_file(path: &StrictPath<(UserFiles, ReadWrite)>, data: &[u8]) -> std::io::Result<()> {
path.write(data)
}
fn delete_user_file(path: &StrictPath<(UserFiles, ReadWrite)>) -> std::io::Result<()> {
path.remove_file()
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Valid token — authorization succeeds
handle_request("valid-token-12345", "notes.txt", Some(b"New content"))?;
// Invalid token — authorization fails
handle_request("invalid-token", "notes.txt", Some(b"Hack attempt"))?;
Ok(())
}
Tuple Markers: Composing Resources and Permissions
Notice we’re using tuple markers: (UserFiles, ReadOnly) and (UserFiles, ReadWrite).
#![allow(unused)]
fn main() {
struct UserFiles; // First element: WHAT resource
struct ReadOnly; // Second element: WHAT permission level
struct ReadWrite;
// Composed together:
// StrictPath<(UserFiles, ReadOnly)> = User files with read-only access
// StrictPath<(UserFiles, ReadWrite)> = User files with read-write access
}
Why tuples?
- ✅ Flexible composition: Mix and match resources with permissions
- ✅ Easy to transform:
change_marker()can swap out permission levels - ✅ Standard Rust idiom: No need to learn special syntax
Try It Yourself: Capability-Based Authorization
Here’s a more sophisticated example with multiple capability levels:
use strict_path::StrictPath;
struct ProjectFiles;
struct CanRead;
struct CanWrite;
struct CanDelete;
// Check user role and return appropriate marker
fn grant_project_access(
user_role: &str,
path: StrictPath<ProjectFiles>
) -> Option<StrictPath<(ProjectFiles, CanRead, CanWrite, CanDelete)>> {
// ✅ Authorization: Role checked
if user_role == "admin" {
// Admin gets full access (read + write + delete)
Some(path.change_marker::<(ProjectFiles, CanRead, CanWrite, CanDelete)>())
} else {
None
}
}
fn grant_editor_access(
user_role: &str,
path: StrictPath<ProjectFiles>
) -> Option<StrictPath<(ProjectFiles, CanRead, CanWrite)>> {
// ✅ Authorization: Role checked
if user_role == "editor" || user_role == "admin" {
// Editors can read and write (but not delete)
Some(path.change_marker::<(ProjectFiles, CanRead, CanWrite)>())
} else {
None
}
}
fn grant_readonly_access(
user_role: &str,
path: StrictPath<ProjectFiles>
) -> Option<StrictPath<(ProjectFiles, CanRead)>> {
// ✅ Authorization: Role checked
if user_role == "viewer" || user_role == "editor" || user_role == "admin" {
Some(path.change_marker::<(ProjectFiles, CanRead)>())
} else {
None
}
}
// Functions require specific capabilities in their signature
fn read_project(path: &StrictPath<(ProjectFiles, CanRead)>) -> std::io::Result<String> {
path.read_to_string()
}
fn update_project(path: &StrictPath<(ProjectFiles, CanRead, CanWrite)>) -> std::io::Result<()> {
path.write(b"Updated project data")
}
fn delete_project(path: &StrictPath<(ProjectFiles, CanRead, CanWrite, CanDelete)>) -> std::io::Result<()> {
path.remove_file()
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let projects_dir: StrictPath<ProjectFiles> =
StrictPath::with_boundary_create("./projects")?;
let project = projects_dir.strict_join("proposal.md")?;
// Viewer can only read
if let Some(readonly_path) = grant_readonly_access("viewer", project.clone()) {
read_project(&readonly_path)?;
println!("✅ Viewer: read succeeded");
// update_project(&readonly_path)?; // ❌ Won't compile: missing CanWrite
}
// Editor can read and write
if let Some(editor_path) = grant_editor_access("editor", project.clone()) {
read_project(&editor_path)?; // ✅ Has CanRead
update_project(&editor_path)?; // ✅ Has CanRead + CanWrite
println!("✅ Editor: read and write succeeded");
// delete_project(&editor_path)?; // ❌ Won't compile: missing CanDelete
}
// Admin can do everything
if let Some(admin_path) = grant_project_access("admin", project) {
read_project(&admin_path)?; // ✅ Has CanRead
update_project(&admin_path)?; // ✅ Has CanRead + CanWrite
delete_project(&admin_path)?; // ✅ Has CanRead + CanWrite + CanDelete
println!("✅ Admin: full access succeeded");
}
Ok(())
}
Head First Moment: Passport Stamps
Think of change_marker() like stamping a passport:
- You apply for a visa (submit token for validation)
- Visa office checks credentials (authorization function validates token)
- If approved, they stamp your passport (call
change_marker()) - Guards at checkpoints check your stamp (functions check marker type)
The stamp doesn’t grant permission — the visa office did that. The stamp just proves permission was granted.
Functions check your stamp (marker), not your visa application (token).
This means:
- ✅ Authorization happens once (at the visa office)
- ✅ Every checkpoint trusts the stamp (no re-checking)
- ✅ Can’t forge a stamp (only way to get marker is through auth function)
- ✅ Compiler ensures you have the right stamp for each checkpoint
The Authorization Pattern Summary
#![allow(unused)]
fn main() {
// 1️⃣ Define resource and permission markers
struct Resource;
struct ReadOnly;
struct ReadWrite;
// 2️⃣ Create authorization gate
fn authorize(token: &str, path: StrictPath<(Resource, ReadOnly)>)
-> Option<StrictPath<(Resource, ReadWrite)>>
{
if validate(token) { // ✅ Check authorization
Some(path.change_marker()) // ✅ Encode in type
} else {
None // ❌ Authorization failed
}
}
// 3️⃣ Functions require authorized marker
fn protected_operation(path: &StrictPath<(Resource, ReadWrite)>) {
// No authorization check needed!
// Type proves authorization already happened.
}
}
Real-World Example: Web API
Here’s how you’d use this in a web server:
#![allow(unused)]
fn main() {
use strict_path::StrictPath;
struct ApiUploads;
struct AuthToken(String);
struct ReadAccess;
struct WriteAccess;
// Authorization: Validate JWT token
fn authorize_write_access(
token: &AuthToken,
path: StrictPath<(ApiUploads, ReadAccess)>
) -> Result<StrictPath<(ApiUploads, ReadAccess, WriteAccess)>, AuthError> {
// ✅ Authorization: Validate JWT token
if verify_jwt(&token.0)? {
Ok(path.change_marker())
} else {
Err(AuthError::InvalidToken)
}
}
fn verify_jwt(token: &str) -> Result<bool, AuthError> {
// Real implementation would:
// - Verify signature
// - Check expiration
// - Validate claims
Ok(token.starts_with("Bearer "))
}
// API handlers
fn handle_read(uploads: &StrictPath<(ApiUploads, ReadAccess)>, filename: &str)
-> Result<Vec<u8>, ApiError>
{
let file = uploads.strict_join(filename)?;
Ok(file.read()?)
}
fn handle_write(
uploads: &StrictPath<(ApiUploads, ReadAccess, WriteAccess)>,
filename: &str,
data: &[u8]
) -> Result<(), ApiError>
{
let file = uploads.strict_join(filename)?;
Ok(file.write(data)?)
}
#[derive(Debug)]
enum AuthError {
InvalidToken,
}
#[derive(Debug)]
enum ApiError {
PathError(strict_path::StrictPathError),
IoError(std::io::Error),
}
impl From<strict_path::StrictPathError> for ApiError {
fn from(e: strict_path::StrictPathError) -> Self {
ApiError::PathError(e)
}
}
impl From<std::io::Error> for ApiError {
fn from(e: std::io::Error) -> Self {
ApiError::IoError(e)
}
}
}
Key Takeaways
✅ change_marker() encodes proven authorization (doesn’t grant it)
✅ Tuple markers compose resources and permissions
✅ Authorization happens once — type system enforces it everywhere
✅ Impossible to bypass — only way to get the marker is through auth gate
✅ Compiler catches missing authorization — won’t compile without proper marker
The Complete Guarantee So Far
If a function accepts
StrictPath<(Resource, Permission)>, the compiler mathematically proves that:
- ✅ The path cannot escape its boundary (Chapter 1)
- ✅ The path is in the correct domain (Chapter 3)
- ✅ Authorization was granted for that permission level (Chapter 4)
This is compile-time authorization. Forget a check? Won’t compile. Use the wrong permission level? Won’t compile. Bypass authorization? Impossible.
What’s Next?
You’ve mastered authorization with markers. But what about user-facing applications where you want to show clean paths like /documents/file.txt instead of ugly system paths?
That’s where VirtualPath comes in…
Continue to Chapter 5: Virtual Paths →
Quick Reference:
#![allow(unused)]
fn main() {
// Define markers
struct Resource;
struct ReadOnly;
struct ReadWrite;
// Authorization gate
fn authorize(token: &str, path: StrictPath<(Resource, ReadOnly)>)
-> Option<StrictPath<(Resource, ReadWrite)>>
{
if validate(token) {
Some(path.change_marker()) // Encode authorization
} else {
None
}
}
// Protected function
fn protected(path: &StrictPath<(Resource, ReadWrite)>) {
// No auth check needed — type proves it!
}
}