PHP Classes

File: docs/proposals/AccessControl_Proposal.md

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   docs/proposals/AccessControl_Proposal.md   Download  
File: docs/proposals/AccessControl_Proposal.md
Role: Auxiliary data
Content type: text/markdown
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 23,151 bytes
 

Contents

Class file image Download

Access Control: Roles and Permission Flags

> Draft Notice: This proposal is a draft generated by AI and may not have been reviewed for accuracy. It is intended as a starting point for discussion and implementation planning.

Table of Contents

  1. Overview
  2. Current State
  3. Goals
  4. Design - Permission Flags - Roles - Role Settings (Non-boolean Values) - User-Role Assignment - Guest Access
  5. Database Schema
  6. Built-in Roles
  7. Example Custom Roles
  8. Code Changes - New AccessControl Class - Auth Integration - Route Guard Updates - Admin Interface
  9. Backward Compatibility
  10. Migration Plan
  11. Future Extensions

Overview

BinktermPHP currently has a binary privilege model: a user is either a regular user or a sysop (is_admin = TRUE). This proposal introduces a roles-and-flags permission system that allows the sysop to define custom roles with fine-grained capabilities. A role is a named collection of permission flags and optional settings. Users can be assigned one or more roles, and their effective permissions are the union of all flags from all their assigned roles.

Current State

  • The `users` table has a single `is_admin` boolean column.
  • All admin panel access and privileged API operations check `$user['is_admin']` directly.
  • There is no middle ground between an ordinary user and a full sysop.
  • Per-user capability restrictions (time limits, premium features) are not currently implemented.

Goals

  • Allow the sysop to define custom roles with specific permission flags.
  • Support built-in system roles (`user`, `sysop`) that cannot be deleted.
  • Allow users to have multiple roles simultaneously (additive permissions).
  • Support both boolean permission flags and scalar settings (e.g., time limit in minutes).
  • Make it possible to implement common delegation scenarios: - File Admin: can manage uploaded files, but not define or delete file areas. - Co-Sysop: can manage file areas, echo areas, and users, but not doors or system config. - Premium User: access to premium taglines, time limit exemption, and possible credit perks.
  • Support a safe anonymous guest mode tied to the existing `_guest` system account.
  • Keep guest access deny-by-default so new user permissions do not silently expand guest capabilities.
  • Preserve backward compatibility with existing `is_admin` checks during a transition period.

Design

Permission Flags

Permission flags are string keys organized into namespaces. A role can have any combination of these flags. The presence of a flag in a role grants that capability; absence denies it.

Admin Flags (admin.*)

| Flag | Description | |------|-------------| | admin.access | May enter the admin panel at all | | admin.manage_files | Upload, delete, rename, and edit file descriptions | | admin.manage_file_areas | Create, edit, and delete file areas; set area permissions | | admin.manage_echo_areas | Create, edit, and delete echo areas | | admin.manage_users | View, create, edit, and deactivate user accounts | | admin.manage_doors | Enable/disable WebDoors, edit door settings | | admin.manage_uplinks | Add, edit, and remove uplink nodes and network configs | | admin.manage_nodelist | Import and manage nodelists | | admin.view_logs | View packet logs, error logs, and admin action logs | | admin.manage_system | Edit system-wide settings (binkp config, BBS config, API keys) |

User Privilege Flags (user.*)

| Flag | Description | |------|-------------| | user.premium_taglines | Access to the premium/extended tagline pool | | user.time_limit_exempt | Not subject to session time limits | | user.sysop_areas | May read and post in sysop-only echo areas and access restricted file areas | | user.no_post_throttle | Not subject to per-hour post rate limits | | user.no_credit_cost | Bypass credit costs for actions that would normally deduct credits |

Guest Flags (guest.*)

These flags are intended for the built-in guest role and other explicitly anonymous access scenarios. Guest access remains deny-by-default even if the regular user role grows broader over time.

| Flag | Description | |------|-------------| | guest.web_login | May establish a guest web session | | guest.terminal_login | May establish a guest telnet terminal session | | guest.use_guest_doors | May launch doors marked for anonymous access | | guest.read_public_echomail | May read echo areas explicitly marked public-to-guests | | guest.read_public_files | May browse file areas explicitly marked public-to-guests | | guest.download_public_files | May download files from guest-visible public areas |

Roles

A role is a named set of permission flags plus optional scalar settings. Roles are stored in the database and managed via the admin interface.

  • System roles (`is_system = TRUE`) cannot be deleted or renamed. The initial system roles are `user` and `guest`.
  • Custom roles are created by the sysop and can be freely modified.
  • A user's effective permissions are the union of all flags across all their assigned roles.
  • is_admin is the authoritative superuser flag. A user with `is_admin = TRUE` bypasses all permission checks entirely, regardless of their assigned roles. This is intentional ? it provides a reliable "break glass" mechanism if roles are misconfigured. The `AccessControl` class reads `is_admin` directly from the user record and short-circuits on it; routes never inspect `is_admin` themselves.
  • Guest access is a special case layered on top of roles: the `_guest` system user should only receive explicitly guest-safe capabilities, not the default registered-user experience.

Role Settings (Non-boolean Values)

Some access controls are not boolean. These are stored in a role_settings table as key-value pairs on a role.

| Setting Key | Type | Description | |-------------|------|-------------| | time_limit_minutes | integer | Maximum session time in minutes per day; 0 = unlimited | | max_downloads_per_day | integer | Download cap per day; 0 = unlimited | | credit_multiplier | float | Multiplier applied to credit rewards (e.g., 1.5 for 50% bonus) |

When a user has multiple roles, the most permissive value applies (highest time_limit_minutes, etc.). A value of 0 is treated as unlimited and overrides everything.

User-Role Assignment

A user may be assigned any number of roles. Assignments can optionally carry an expiration date (e.g., a temporary premium subscription). Assignments record who granted them and when, creating an audit trail.

The _guest system account is special:

  • It should not be managed through the normal user-role editor.
  • It should not automatically inherit the `user` role.
  • It should instead be assigned only the built-in `guest` role (or another explicitly guest-scoped system role if the design evolves).

Guest Access

BinktermPHP already has a partial guest model for native doors using a shared _guest system account. This proposal extends that idea into the broader access-control design rather than inventing a parallel anonymous permission system.

Principles:

  • Guest sessions are real sessions bound to the `_guest` user record.
  • Guest access is deny-by-default.
  • Guest permissions are explicitly granted via `guest.*` flags.
  • Regular `user.*` role growth must not silently broaden guest access.
  • `is_admin` remains the only superuser bypass; guest never bypasses anything.

Initial scope should be intentionally narrow:

  • Web guest login, if enabled
  • Guest doors already supported by the native door system
  • Optional telnet guest login
  • Read-only access to explicitly public guest-safe content

Out of scope for the first implementation:

  • Netmail
  • Echomail posting/replying
  • Uploads
  • Account settings or any user-profile mutation
  • Credits, purchases, or premium features
  • Admin access of any kind

Implementation note:

AccessControl should expose an isGuest() concept and guest-aware route helpers such as requireNonGuest() for state-changing operations. Permission evaluation should follow this order:

  1. `is_admin` superuser bypass
  2. Guest restrictions / guest allowlist
  3. Normal role-based permission resolution

This keeps guest handling centralized instead of scattering one-off checks throughout routes.

Database Schema

Migration file: database/migrations/vX.Y.Z_access_control_roles.sql

-- Roles table
CREATE TABLE IF NOT EXISTS roles (
    id          SERIAL PRIMARY KEY,
    name        VARCHAR(64) NOT NULL,
    description TEXT,
    is_system   BOOLEAN NOT NULL DEFAULT FALSE,
    created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
    UNIQUE(name)
);

-- Per-role boolean permission flags
CREATE TABLE IF NOT EXISTS role_permissions (
    role_id    INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
    permission VARCHAR(64) NOT NULL,
    PRIMARY KEY (role_id, permission)
);

-- Per-role scalar settings (time limits, multipliers, etc.)
CREATE TABLE IF NOT EXISTS role_settings (
    role_id     INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
    setting_key VARCHAR(64) NOT NULL,
    value       TEXT NOT NULL,
    PRIMARY KEY (role_id, setting_key)
);

-- Assignments of roles to users
CREATE TABLE IF NOT EXISTS user_role_assignments (
    id          SERIAL PRIMARY KEY,
    user_id     INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role_id     INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
    granted_by  INTEGER REFERENCES users(id) ON DELETE SET NULL,
    granted_at  TIMESTAMP NOT NULL DEFAULT NOW(),
    expires_at  TIMESTAMP,
    UNIQUE(user_id, role_id)
);

CREATE INDEX IF NOT EXISTS idx_user_role_assignments_user ON user_role_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_user_role_assignments_expires ON user_role_assignments(expires_at)
    WHERE expires_at IS NOT NULL;

-- Seed built-in system role
INSERT INTO roles (name, description, is_system) VALUES
    ('user', 'Default role assigned to all registered users', TRUE),
    ('guest', 'Restricted role used only by the _guest system account', TRUE)
ON CONFLICT (name) DO NOTHING;

-- Assign the user role to all existing users
INSERT INTO user_role_assignments (user_id, role_id, granted_by, granted_at)
SELECT u.id, r.id, NULL, NOW()
FROM users u
CROSS JOIN roles r
WHERE r.name = 'user'
  AND u.username <> '_guest'
ON CONFLICT DO NOTHING;

-- Assign the guest role to the shared guest account only
INSERT INTO user_role_assignments (user_id, role_id, granted_by, granted_at)
SELECT u.id, r.id, NULL, NOW()
FROM users u
CROSS JOIN roles r
WHERE u.username = '_guest'
  AND u.is_system = TRUE
  AND r.name = 'guest'
ON CONFLICT DO NOTHING;

Built-in Roles

| Role | System | Flags | Notes | |------|--------|-------|-------| | user | Yes | _(none by default)_ | Assigned to every registered user automatically on creation | | guest | Yes | guest-safe capabilities only | Assigned only to the _guest system account |

Superuser access is not a role. It is conferred by is_admin = TRUE on the user record and is handled transparently by AccessControl. Custom roles named "sysop" or similar can be created for organizational clarity, but they carry no special treatment in code.

The _guest account is a special system user used for anonymous sessions such as guest doors and any future guest web/telnet login flow. It should never be treated as a normal registered user for role seeding purposes.

Example Custom Roles

These are examples the sysop could create via the admin interface. They are not seeded automatically.

File Admin

| Flag | Granted | |------|---------| | admin.access | ? | | admin.manage_files | ? | | admin.manage_file_areas | ? | | All others | ? |

Co-Sysop

| Flag | Granted | |------|---------| | admin.access | ? | | admin.manage_files | ? | | admin.manage_file_areas | ? | | admin.manage_echo_areas | ? | | admin.manage_users | ? | | admin.view_logs | ? | | admin.manage_doors | ? | | admin.manage_uplinks | ? | | admin.manage_system | ? |

Premium User

| Flag / Setting | Value | |----------------|-------| | user.premium_taglines | ? | | user.time_limit_exempt | ? | | credit_multiplier | 1.5 |

Code Changes

New AccessControl Class

A new src/AccessControl.php class centralizes all permission queries. It is instantiated with a user ID (or the user array from Auth) and caches the resolved permission set.

namespace BinktermPHP;

class AccessControl
{
    private array $permissions = [];
    private array $settings    = [];
    private bool  $isSysop     = false;
    private bool  $isGuest     = false;

    public function __construct(private int $userId) {
        $this->load();
    }

    /
     * Create from the user array returned by Auth::getCurrentUser().
     * Reads is_admin directly so the superuser bypass is always authoritative.
     */
    public static function forUser(array $user): self {
        $instance = new self((int)($user['user_id'] ?? $user['id']));
        // is_admin short-circuits all permission checks ? no role lookup needed
        if (!empty($user['is_admin'])) {
            $instance->isSysop = true;
        }
        if (($user['username'] ?? '') === '_guest' || !empty($user['is_guest'])) {
            $instance->isGuest = true;
        }
        return $instance;
    }

    public function isGuest(): bool {
        return $this->isGuest;
    }

    /
     * Check whether the user has a specific permission flag.
     */
    public function can(string $permission): bool {
        if ($this->isSysop) {
            return true;
        }

        if ($this->isGuest && !$this->isGuestPermissionAllowed($permission)) {
            return false;
        }

        return in_array($permission, $this->permissions, true);
    }

    /
     * Require a permission or abort with 403.
     */
    public function require(string $permission): void {
        if (!$this->can($permission)) {
            http_response_code(403);
            header('Content-Type: application/json');
            echo json_encode(['success' => false, 'error' => 'Forbidden']);
            exit;
        }
    }

    /
     * Return the effective scalar setting value across all roles.
     * Returns the most permissive (highest) value, with 0 = unlimited.
     */
    public function getSetting(string $key, mixed $default = null): mixed {
        return $this->settings[$key] ?? $default;
    }

    public function requireNonGuest(): void {
        if ($this->isGuest) {
            http_response_code(403);
            header('Content-Type: application/json');
            echo json_encode(['success' => false, 'error' => 'Guest access is read-only']);
            exit;
        }
    }

    private function load(): void {
        $db = Database::getInstance()->getPdo();

        // Collect flags and settings from all active role assignments.
        // is_admin superuser bypass is handled in forUser() before load() runs.
        $stmt = $db->prepare("
            SELECT rp.permission, rs.setting_key, rs.value
            FROM user_role_assignments ura
            JOIN roles r ON ura.role_id = r.id
            LEFT JOIN role_permissions rp ON rp.role_id = r.id
            LEFT JOIN role_settings rs ON rs.role_id = r.id
            WHERE ura.user_id = ?
              AND (ura.expires_at IS NULL OR ura.expires_at > NOW())
        ");
        $stmt->execute([$this->userId]);
        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);

        foreach ($rows as $row) {
            if ($row['permission'] !== null) {
                $this->permissions[] = $row['permission'];
            }
            if ($row['setting_key'] !== null) {
                $this->mergeSetting($row['setting_key'], $row['value']);
            }
        }

        $this->permissions = array_unique($this->permissions);
    }

    private function mergeSetting(string $key, string $raw): void {
        $value = is_numeric($raw) ? ($raw + 0) : $raw;

        // 0 = unlimited ? overrides everything
        if (!isset($this->settings[$key])) {
            $this->settings[$key] = $value;
            return;
        }

        $existing = $this->settings[$key];
        if ($existing === 0 || $value === 0) {
            $this->settings[$key] = 0;
        } else {
            // Most permissive: highest numeric value wins
            $this->settings[$key] = max($existing, $value);
        }
    }

    private function isGuestPermissionAllowed(string $permission): bool {
        return str_starts_with($permission, 'guest.');
    }
}

Auth Integration

Auth::validateSession() already returns is_admin. No changes are required to Auth.php. Callers that need fine-grained checks instantiate AccessControl::forUser($user), passing the full user array. AccessControl reads is_admin from that array and sets the internal $isSysop flag before doing any role lookups ? so a superuser never needs a role assignment to pass any permission check.

For convenience, a helper method Auth::requirePermission(string $permission) can be added that calls requireAuth() and then checks the permission in one step.

For guest support, Auth should also expose a helper for creating a normal session tied to the _guest system user, rather than bypassing the session layer for anonymous traffic.

Route Guard Updates

Existing guards like if (!$user['is_admin']) { ... } in routes are replaced progressively:

// Before
if (!$user['is_admin']) {
    return apiError('errors.auth.forbidden', 'Forbidden', 403);
}

// After
$acl = AccessControl::forUser($user);
$acl->require('admin.manage_file_areas');

Admin panel page guards in web-routes.php check admin.access rather than is_admin:

$acl = AccessControl::forUser($user);
if (!$acl->can('admin.access')) {
    return redirect('/');
}

Guest-sensitive write paths should use an explicit guest guard in addition to feature permissions:

$acl = AccessControl::forUser($user);
$acl->requireNonGuest();

Admin Interface

A new admin section Roles & Permissions is added at /admin/roles with the following pages:

Role List (/admin/roles)

  • Table of all roles with name, description, user count, system/custom badge.
  • Buttons: Create Role, Edit, Delete (disabled for system roles).

Role Editor (/admin/roles/edit/{id})

  • Name and description fields (disabled for system roles).
  • Checkbox grid for all known permission flags, grouped by namespace (`admin.,user.`).
  • Settings fields for scalar values (time limit, download cap, credit multiplier).
  • Save and Cancel buttons.

User Role Management (on existing User Edit page)

  • Multi-select (or add/remove) list of roles assigned to a user.
  • Each assignment shows granted date, expiration (if any), and who granted it.
  • Sysop can set an expiration date when assigning (for temporary roles like premium subscriptions).

Backward Compatibility

  1. is_admin is permanent, not transitional. It is the authoritative superuser flag and will not be removed. It replaces the need for a `sysop` role in code.
  2. Migration seeds the user role for all existing users except the `_guest` system account. The `_guest` account receives the `guest` role only.
  3. Gradual route migration: Routes can be migrated one at a time. Unmigrated routes continue to check `is_admin` directly. Migrated routes use `AccessControl::forUser($user)->can(...)` or `->require(...)` instead.
  4. AdminController::requireAdmin(): During the transition, this can check `is_admin` OR `admin.access` so delegated admins can reach the panel while the full migration is in progress.

Migration Plan

Phase 1 ? Schema and Seed (single migration)

  • Create `roles`, `role_permissions`, `role_settings`, `user_role_assignments` tables.
  • Seed the `user` and `guest` system roles.
  • Assign the `user` role to all existing users except `_guest`.
  • Assign the `guest` role to `_guest`.
  • No action needed for existing `is_admin` users ? their flag is already the superuser bypass.

Phase 2 ? New AccessControl Class

  • Implement `src/AccessControl.php`.
  • Add helper to `Auth` for convenience.
  • Add guest awareness (`isGuest`, guest-safe allowlist, `requireNonGuest`).
  • Write unit tests for permission resolution, guest restrictions, and setting merging.

Phase 3 ? Admin Interface

  • Add Roles & Permissions admin page.
  • Add role assignment UI to user edit page.
  • Ensure new user creation automatically assigns the `user` role.
  • Exclude the `_guest` account from normal role-editing workflows.

Guest Rollout Overlay

  • Before broad route migration, add guest session creation helpers for web login and optional telnet login.
  • Reuse the existing `_guest` system account rather than creating pseudo-users.
  • Migrate guest-safe public routes separately from authenticated write routes.
  • Limit guest sessions to explicitly whitelisted, read-only capabilities.

Phase 4 ? Route Migration

  • Migrate each `is_admin` check in routes to the appropriate `AccessControl` flag.
  • Prioritize the admin panel routes and file/echoarea management routes first.
  • Migrate WebDoor and system settings routes.

Phase 5 ? Enforce Role Scalar Settings

  • Hook `time_limit_minutes` into session activity tracking.
  • Hook `credit_multiplier` into `UserCredit` reward calculations.
  • Hook `max_downloads_per_day` into file download tracking.

Phase 6 ? Cleanup (future release)

  • Remove remaining direct `is_admin` checks from routes (they will all go through `AccessControl` at this point).
  • `is_admin` itself stays on the `users` table permanently as the superuser flag.

Future Extensions

  • Per-area overrides: A role could grant or deny access to specific echo areas or file areas beyond the global `user.sysop_areas` flag.
  • Automatic role assignment rules: Auto-assign a premium role when a user subscribes, or promote users based on post count.
  • Role inheritance / parent roles: Allow a co-sysop role to extend the base user role without repeating flags.
  • Telnet BBS integration: The same role system applies to telnet sessions; `DoorDropFile` security levels can be derived from roles.
  • API key scoping: Future API keys issued to bots or external services could carry a role limiting what the key can do.
  • Per-area guest overrides: Allow selected echo areas and file areas to opt into guest visibility/download without exposing the full regular-user view.