YOURLS-RBAC

September 9, 2026 · View on GitHub

Listed in Awesome YOURLS! Tests PHP PHPUnit YOURLS Contributions welcome Playwright

YOURLS-RBAC

Role-Based Access Control (RBAC) user management for YOURLS URL shortener.

Users, roles, and permissions are stored in the database, giving you a full admin interface for managing who can do what within YOURLS — rather than the default single-user password in user/config.php.

Based on the RBAC model[1].

Features

  • Database-backed user accounts — users are no longer limited to the single $yourls_user_passwords entry in config.php.
  • Roles — group users into logical roles (Administrator, Manager, Editor, User).
  • Permissions — granular capability checks (manage_urls, view_stats, manage_users, manage_roles, manage_permissions, manage_plugins, manage_tools, access_admin).
  • Enforced everywhere — permission checks gate core admin pages, AJAX actions, the API, and plugin (de)activation, not just this plugin's own pages (see Permission enforcement).
  • Admin UI — built-in admin pages for managing users, roles, and permissions from the YOURLS admin area.
  • Backward compatible — existing config.php users still work alongside database users.

Installation

  1. In /user/plugins/, create a directory rbac and drop these files in it.

  2. Go to Admin → Plugins and activate YOURLS-RBAC.

  3. The plugin automatically creates five tables (using your YOURLS_DB_PREFIX):

    • yourls_rbac_users
    • yourls_rbac_roles
    • yourls_rbac_permissions
    • yourls_rbac_user_roles
    • yourls_rbac_role_permissions
  4. Default roles and permissions are seeded on activation:

    • Administrator — full access (all permissions, including manage_tools)
    • Manageraccess_admin, manage_urls, view_stats, manage_tools
    • Editoraccess_admin, manage_urls, view_stats
    • Useraccess_admin

    A first admin user is seeded from YOURLS_USER / YOURLS_PASSWD (or the first $yourls_user_passwords entry when YOURLS_USER is unset) only if that password passes the RBAC policy — at least 8 characters and not already a phpass/md5 hash. If it is empty or too weak, no user is seeded — create the first admin manually, then log in via Admin → User Management. Seeding is versioned (rbac_seed_version option): a schema bump re-seeds on the next admin_init, cheaply skipped otherwise.

  5. Create users from Admin → Plugins → your plugin admin pages → Users.

Usage

Admin interface

After activation, a User Management entry appears in the admin menu. It contains three sub-pages:

PagePermission required
Usersmanage_users
Rolesmanage_roles
Permissionsmanage_permissions

Programmatic permission checks

// Check if the current user can manage URLs
if (yourls_rbac_can('manage_urls')) {
    // do something
}

// Check if the current user has a specific role
if (yourls_rbac_has_role('admin')) {
    // full access
}

// Require a permission — dies with 403 if unauthorized
yourls_rbac_require('manage_users');

How authentication works

  1. On plugins_loaded, the plugin pulls all active users from the yourls_rbac_users table and merges them into YOURLS's global $yourls_user_passwords array.
  2. YOURLS's native auth flow — login form, cookies, API signatures — then works unchanged, using the database-stored password hashes.
  3. After authentication, YOURLS_USER is set to the logged-in username.
  4. Permission/role checks look up that username in the RBAC tables.

This means login, logout, cookie expiry, and "remember me" all behave exactly as in stock YOURLS.

RBAC enforcement only applies to authenticated requests, so your install must be private (YOURLS_PRIVATE defaults to true). On a public install there is nothing to enforce — anyone can hit the admin area anonymously.

Permission enforcement

Permissions are enforced at the YOURLS seams — no core files are modified:

SurfaceHookEnforced permission
Core admin pages (index.php, tools.php, plugins.php, upgrade.php)auth_successful + page mapaccess_admin / manage_tools / manage_plugins
Plugin (de)activation (plugins.php?action=activate|deactivate)auth_successfulmanage_plugins
AJAX URL mutations (add, edit_display, edit_save, delete)auth_successful + AJAX mapmanage_urls
API writes (shorturl)auth_successful + API mapmanage_urls
API reads (stats, db-stats, url-stats, expand)auth_successful + API mapview_stats
All URL writes, any entry pointshunt_add_new_link, shunt_edit_link, shunt_edit_link_title, shunt_delete_link_by_keywordmanage_urls
RBAC's own admin pagespage callbacksmanage_users / manage_roles / manage_permissions

Denials are fail-closed: API gets a JSON 403, AJAX a JSON 403, HTML pages a yourls_die() 403. A user not found in the RBAC tables has no permissions — add them (with a role) from the Users page before they can do anything.

Additional lockout guards (all enforced server-side):

  • The last active administrator cannot be deleted or demoted.
  • You cannot deactivate or delete your own account.
  • The admin role slug cannot be renamed or deleted; its permission set is always every permission.
  • Core permission slugs (access_admin, manage_users, manage_roles, manage_permissions) cannot be deleted or renamed.
  • You cannot remove manage_roles from a role assigned to yourself.
  • Destructive actions (delete user/role/permission) are POST-only with nonces — no state changes via GET links.
  • Deleting a non-protected permission (e.g. manage_urls, view_stats, manage_plugins, manage_tools) is allowed, but roles holding it silently lose that capability — assign carefully.

Database schema

yourls_rbac_users
  id, username, password, email, active, created_at, updated_at

yourls_rbac_roles
  id, name, slug, description, created_at

yourls_rbac_permissions
  id, name, slug, description, created_at

yourls_rbac_user_roles        (many-to-many: users ↔ roles)
  user_id, role_id

yourls_rbac_role_permissions  (many-to-many: roles ↔ permissions)
  role_id, permission_id

Uninstall

When the plugin is deactivated, RBAC data is kept by default — re-activating the plugin restores users, roles, and permissions seamlessly.

To drop all five tables on deactivation instead, add to your config.php:

define('YOURLS_RBAC_DROP_DATA', true);

Warning: dropping the tables deletes every RBAC user. Make sure the config.php account (YOURLS_USER / YOURLS_PASSWD) still works before enabling this, or you will be locked out.

Contributing

Commit messages

Commits follow the Conventional Commits standard:

<type>[optional scope]: <short description in lowercase, imperative mood>

[optional body: motivation and what changed, wrapped at 72 chars]

[optional footer: BREAKING CHANGE: <...>, Refs: #<issue>]

Types used in this project:

TypeWhen to use
featNew feature or functionality
fixBug fix or security hardening
testAdding or updating tests
docsDocumentation changes (README, AGENTS.md, etc.)
choreBuild tooling, dependencies, CI, gitignore
refactorCode restructuring without behavior change
styleFormatting, whitespace, cosmetic changes

A commit that introduces a breaking change (e.g. a permission slug is renamed) must note it in the footer: BREAKING CHANGE: manage_tools renamed to ....

Testing conventions

  • Tests use PHPUnit, run with vendor/bin/phpunit --testdox.
  • Every test follows the Arrange / Act / Assert (AAA) principle — one behavior per test, no test logic in the Act phase, exactly one logical assertion group.
  • Use Given / When / Then comments to make each phase explicit:
public function testValidatePassword_WithLength7_ThrowsInvalidArgumentException(): void
{
    // Given: a password one character below the minimum length
    // When: validated
    // Then: an InvalidArgumentException with a helpful message is thrown
    $this->expectException(InvalidArgumentException::class);
    $this->expectExceptionMessage('Password must be at least 8 characters');
    Rbac::validate_password('abcdefg');
}
  • Test method naming convention: testMethodName_WithCondition_ExpectedResult (e.g. testValidateUsername_WithSqlInjectionAttempt_ThrowsInvalidArgumentException).
  • Tests for the pure logic in includes/rbac.php (validation, permission maps, denial payloads) live in tests/Unit/. The test bootstrap (tests/bootstrap.php) provides YOURLS stubs so tests run without a full YOURLS installation.
  • Security-relevant behavior (injection attempts, permission maps, lockout guards) always gets a test — a fix without a regression test is incomplete.

Development

Docker (quick start)

docker compose up --build
# Visit http://localhost:8080
# Login: admin / password123 (dev convenience only — RBAC accepts it
# because it is 11 chars; production installs must use strong passwords)

The Docker environment runs YOURLS (latest from GitHub), a MariaDB container, and mounts the RBAC plugin source as read-only volumes so code changes are reflected immediately without rebuilding.

Local testing (without Docker)

You can also run the unit tests without a full YOURLS installation:

composer install
vendor/bin/phpunit --testdox

End-to-end tests (Playwright)

With the Docker environment running:

cd tests/e2e
npm install
npm test

The suite covers the admin pages (users, roles, permissions), the full user lifecycle (create → assign role → edit → delete), role and permission CRUD with permission-assignment sync, the field-name collision regression, permission enforcement (403 for under-privileged users, URL write/edit/delete denial), lockout guards (self-delete, self-deactivation, demote-last-admin), inactive-user login refusal, and the protected-slug/permission guards. Tests run serialized against a shared DB (reset to seed state by global-setup.js before each run) and use system Chrome (channel: 'chrome') so no browser download is needed. Set RBAC_E2E_BASE_URL if the app is not on port 8081.

References

[1] R.S. Sandhu, E.J. Coyne, H.L. Feinstein, C.E. Youman (1996), Role-Based Access Control Models, IEEE Computer 29(2), (February 1996). [DOI] · Preprint