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#
- Blade escapes by default.
{!! !!}appears only forrichtextandhtmlfield values and is grepped for in a test, which fails if it appears anywhere new without an allow-list entry. - A strict
Content-Security-Policyis not shipped by default, because the HTML block and the analytics snippets exist to inject third-party code and a broken CSP looks like a broken site to a non-technical owner. Instead:X-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN(relaxed only on the preview route),Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policyminimal, HSTS in production. ASettings -> Advanced -> Enable strict CSPtoggle is offered with a clear warning, defaulting to off and with a report-only mode first. - Admin pages send
X-Robots-Tag: noindexand are excluded inrobots.txt.
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 |
| 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.