K KodePressdocumentation
Laravel 11 · MySQL 8 Specification Built 2026-10-10

11 — Roles, permissions and security#

11.1 Roles#

Four roles, created by the seeder, named in lang/*/roles.php.

Role Purpose Can Cannot
Admin the owner everything —
Editor runs the content create, edit, publish and trash pages and posts; manage taxonomy and media; edit menu items; override a page's header/footer; read form submissions theme, header/footer design, mega-menu layout, users, settings, backup, HTML block
Writer contributes create and edit their own drafts, upload media, submit for review publish anything, edit other people's content, touch taxonomy structure, menus, design
Designer the look theme tokens, headers, footers, mega-menu panels, templates, global blocks, custom CSS create or edit page content, read form submissions, manage users or settings

Roles are additive via spatie: a user may hold Editor and Designer. The seeded Admin holds every permission directly as well, so a permission added by a future phase or plugin does not silently lock the owner out — a test asserts Admin has every registered permission.

11.2 Permission list#

Names are area.action. Policies map to these; nothing checks a role name in a controller.

Area Permissions
Pages pages.view, pages.create, pages.update, pages.update.own, pages.delete, pages.publish, pages.restore, pages.approve
Posts reuse the pages.* set (a post is a page); posts.schedule for scheduling
Taxonomy taxonomy.view, taxonomy.manage
Media media.view, media.upload, media.update, media.delete
Menus menus.view, menus.items, menus.manage, menus.mega
Design design.theme, design.parts, design.templates, design.globalblocks, design.customcode
Forms forms.manage, forms.submissions.view, forms.submissions.delete, forms.submissions.export
Settings settings.site, settings.seo, settings.redirects, settings.users, settings.backup, settings.audit
System system.blocks.refresh, system.cache.clear, system.plugins (Phase 5)

Role grants:

Permission group Admin Editor Writer Designer
pages.view yes yes yes no
pages.create yes yes yes no
pages.update yes yes no (.own only) no
pages.publish / pages.approve yes yes no no
pages.delete / pages.restore yes yes no no
taxonomy.manage yes yes no no
media.upload yes yes yes no
media.delete yes yes no no
menus.items yes yes no yes
menus.manage / menus.mega yes no no yes
design.* except customcode yes no no yes
design.customcode yes no no no
forms.manage yes no no no
forms.submissions.* yes yes no no
settings.* yes no no no
system.* yes no no no

design.customcode gates the HTML block, theme_settings.custom_css, custom_head and the Advanced raw-value fields. It is Admin-only because it is, in effect, the ability to run JavaScript on every visitor's browser.

11.3 Policies#

Policy Governs Notable rules
PagePolicy pages and posts update: pages.update, or pages.update.own and author_id === user->id and status is not published. publish: pages.publish. approve: pages.approve and the page is pending
MediaPolicy media delete: media.delete; a Writer may delete only their own uploads within 24 hours
MenuPolicy menus, items, mega updateItems vs manage vs editMega as the table above
TemplatePartPolicy headers, footers update/publish: design.parts
ThemePolicy tokens, custom CSS customCode: design.customcode
FormPolicy forms and submissions separate abilities for managing a form and reading its submissions
UserPolicy users nobody may delete themselves; the last Admin cannot be demoted or deactivated
SettingsPolicy settings screens one ability per screen

Every admin controller and Livewire component calls authorize(). Hiding a button is a UX courtesy, never the control. A test hits every admin route as each role and asserts the matrix above — the authorization sweep is the test that must never be skipped, because this is the class of bug that quietly exposes a whole site.

11.4 Approval flow (optional)#

Enabled in Settings -> Site info -> Require approval before publishing.

Writer edits a draft -> "Submit for review" -> pages.status = pending
   -> Editors notified (in-app Dashboard list + email if configured)
   -> Editor opens it, sees a "Pending review" banner with Approve & publish / Request changes
        Approve & publish -> normal publish pipeline
        Request changes   -> status back to draft, with a note stored in audit_logs and shown to the author

While a page is pending, the author can still edit it (the draft tree is theirs) but cannot publish. The reviewer sees the draft, not a frozen copy — simpler to reason about and it matches what the author sees.

11.5 Authentication#

Concern Rule
Login email + password, Breeze Blade scaffolding, our own styling
Rate limit 5 attempts per email+IP per minute, then a 1-minute lockout; a named throttle so it never shares a counter with another route
Password rules minimum 10 characters, must not be in the bundled common-password list, confirmed on change; no forced rotation
Password reset signed token email, 60-minute expiry, single use
Sessions database driver, 120-minute idle lifetime, SESSION_SECURE_COOKIE=true in production, SameSite=Lax, regenerated on login
Logout everywhere available in the profile screen; invalidates other sessions
2FA optional TOTP (authenticator app) per user, mandatory-for-Admin switch in Settings; 8 single-use recovery codes, shown once
Email / SMS codes any emailed or texted one-time code in this project is 4 digits, with a 10-minute expiry and a 3-attempt cap. Authenticator-app codes stay 6 digits because RFC 6238 defines them that way
Admin URL kodepress.admin_path, default admin, changeable in Settings; changing it logs the change and shows the new URL once, with a warning to save it
Failed logins written to audit_logs with IP; 10 failures for one email in an hour notifies the Admin

Changing the admin path is obscurity, not security, and the Settings screen says so in one sentence. It is there because on shared hosting it meaningfully reduces bot noise in the logs.

11.6 Input handling#

Input Treatment
Block field values validated against schema.php rules, coerced, unknown keys dropped
Rich text server-side allow-list sanitisation (04); never trusted from the client
HTML block stored verbatim, writable only with design.customcode; the editor shows who last changed it
Uploads extension + MIME + re-encode (10)
Form submissions validated against the form definition; files run the same upload checks
Slugs and paths slugified, length-capped, collision-checked; path traversal characters removed
Redirect targets must be a relative path or an absolute http(s) URL; javascript: and data: rejected
Search queries parameterised; normalised, length-capped at 100 chars
Imported templates / menus / block packs parsed, validated as a tree, context-checked, sanitised — an import is untrusted input, not a shortcut past validation

11.7 Output handling#

11.8 Audit log#

Logged: create, update, delete, restore, publish, unpublish, schedule, approve, reject, login, failed login, logout, role change, settings change, theme publish, part publish, menu change, media delete, backup, restore, plugin enable/disable.

Each row records who (id plus denormalised name), what (model, id, readable label), when, from where (IP, user agent) and a scalar-field diff. Block trees are never diffed into the log — version history is the right tool for that, and a JSON diff would make the log unreadable.

Settings -> Audit log is filterable by user, action, subject type and date, exportable as CSV, and read-only — no UI deletes rows. Retention is kodepress.audit.keep_days (default 365), pruned nightly.

11.9 Hardening checklist for a deploy#

Verified by php artisan kodepress:doctor, which prints a pass/fail list:

Check Expected
APP_DEBUG false
APP_ENV production
APP_KEY set
.env reachable over HTTP no
storage/ and vendor/ reachable over HTTP no
PHP execution under storage/app/public disabled
/.git reachable over HTTP no
SESSION_SECURE_COOKIE true (when HTTPS)
Admin path not the default, or explicitly accepted
Default Admin password changed
Cron last tick under 5 minutes old
Writable storage/, bootstrap/cache/ only
Backup one exists and is newer than 7 days
TLS certificate valid, from a real issuer, more than 14 days to expiry
Mail a test mail sends

kodepress:doctor runs as part of the deploy script and its output is kept with the release notes.

11.10 Tests for this document#

Test Asserts
permission_matrix_sweep every admin route, as every role, matches the table in 11.2
admin_has_every_registered_permission including ones added later
writer_cannot_publish 403, and the page stays draft/pending
writer_cannot_edit_others_draft 403
writer_cannot_edit_own_published_page 403 (publishing takes it out of their hands)
designer_cannot_read_submissions 403
editor_cannot_open_advanced_custom_css field absent and the endpoint 403s
html_block_rejected_for_non_admin publish blocked with a named error
richtext_strips_script_and_onclick stored value is clean
login_throttle_is_named a different route does not share the counter
last_admin_cannot_be_demoted 422 with a translated message
email_otp_is_four_digits and expires in 10 minutes after 3 attempts
audit_row_written_for_publish with subject label and a scalar diff only
admin_pages_are_noindex header present

KodePress documentation · generated from the Markdown sources by tools/build-docs-site.py · internal preview, not indexed.