Skip to content

Authorization Architecture ​

Vulcan uses a deny-by-default authorization model enforced by an automated test. Every routed controller action must have an explicit authorize_* before_action callback — actions without one cause the test suite to fail.

Layers ​

  1. Authentication (authenticate_user!) — Devise, applied globally via ApplicationController. Answers: "Who are you?"
  2. Authorization (authorize_* callbacks) — Custom, per-action. Answers: "What can you do?"

Both layers are required. Authentication alone is never sufficient for a controller action.

Role Hierarchy ​

Site Admin (global)
    └── can do everything on every project and component

Project/Component Roles (scoped):
    admin > reviewer > author > viewer

Each level includes all permissions of lower levels. A project admin can do everything a reviewer, author, and viewer can do. Roles are assigned per-project or per-component via Membership records.

Available roles: viewer, author, reviewer, admin

Site Admin vs Project/Component Admin ​

  • Site admin (User#admin == true): Full access to everything — all projects, all components, user management, SRG/STIG uploads. Set via database or admin bootstrap.
  • Project admin: Full access to one project — can update/delete the project, manage members, create/delete components. Automatically assigned when a user creates a project.
  • Component admin: Full access to one component — can delete the component, lock/unlock controls, manage component members. Can be assigned by project admin.

Effective Permissions (Components) ​

Components have dual membership — a user's effective permission on a component is the higher of:

  1. Their project-level membership role (inherited)
  2. Their component-level membership role (direct)

Example: A user with viewer role on a project but admin role on a specific component within that project has admin permissions on that component.

This is computed by User#effective_permissions(component) and passed to Vue as the effective_permissions prop.

User-Facing Permissions Summary ​

Projects ​

ActionWho
Browse project listAny logged-in user
Create a projectSite admin, or any user when create_permission_enabled is on
View project detailsProject member (viewer+) or site admin
Export projectProject member (viewer+) or site admin
Update project name/descriptionProject admin or site admin
Delete a projectProject admin or site admin
Manage project membersProject admin or site admin

When a user creates a project, they are automatically assigned the admin role on that project. This means project creators can update, delete, and manage members on their own projects without being a site admin.

Components ​

ActionWho
View (unreleased)Project member (viewer+) or site admin
View (released)Any logged-in user
CreateProject admin or site admin
Edit rulesComponent author+ (not if released)
Edit advanced fields (status, severity)Component admin or site admin
DeleteComponent admin or site admin
Lock/unlock controlsComponent admin or site admin
Compare componentsViewer on both components (or released)

Released components are read-only — even authors and reviewers cannot edit rules on a released component. Only site admins bypass this restriction.

Rules ​

ActionWho
View rulesComponent viewer+
Create/update/revertComponent author+
DeleteComponent admin or site admin
Post a comment review (action: 'comment')Component viewer+
React to a comment (👍/👎)Project viewer+, only when comment_phase='open'
List reactor namesProject viewer+ (works in any phase — historical visibility)
Request review / Revoke review requestComponent author+
Approve / Request changesComponent reviewer+
Lock / UnlockComponent admin or site admin

About comments. Project viewers can post a comment-action review (no rule state change, just text). All other review actions require higher privileges. The controller filter for POST /rules/:id/reviews is authorize_viewer_project; the per-action role gate lives in the model (Review#can_approve, Review#can_request_changes, etc.) so a viewer attempting action: 'approve' fails validation with 422.

About reactions. POST /reviews/:id/reactions toggles the current user's 👍/👎; GET /reviews/:id/reactions lists reactor names. Both gated via authorize_viewer_project. POST is additionally phase-gated (must be comment_phase='open'); GET is intentionally not phase-gated so historical reactions stay visible during closed periods. set_review returns a soft 403 ("isn't available") for missing, non-comment, or non-member reviews so a non-member can't probe review IDs.

Memberships ​

ActionWho
Add members to projectProject admin or site admin
Add members to componentComponent admin or site admin
Update member roleAdmin of the target project/component
Remove a memberAdmin of the target project/component

Access Requests ​

ActionWho
Request access to a discoverable projectAny logged-in user
Cancel own access requestThe requesting user
Approve/deny access requestsProject admin or site admin

SRGs and STIGs ​

ActionWho
View and exportAny logged-in user
Upload newSite admin only
DeleteSite admin only

Users ​

ActionWho
View user listSite admin only
Update user (promote to admin, etc.)Site admin only
Delete userSite admin only
ActionWho
Global searchAny logged-in user (results scoped to user's accessible projects)

Project Visibility ​

Projects have a visibility setting:

  • Discoverable: Appears in project list for all users. Non-members can see the project name/description and request access.
  • Hidden: Only visible to project members and site admins.

Visibility does NOT grant access to project contents — only membership does.

Authorization Methods Reference ​

Global ​

MethodRequirement
authorize_logged_inAny authenticated user
authorize_admincurrent_user.admin == true (site admin)
authorize_admin_or_create_permission_enabledSite admin OR Settings.project.create_permission_enabled

Project-scoped ​

MethodChecks
authorize_viewer_projectcan_view_project?(@project) — site admin OR membership with any role
authorize_author_projectcan_author_project?(@project) — site admin OR membership with author+
authorize_review_projectcan_review_project?(@project) — site admin OR membership with reviewer+
authorize_admin_projectcan_admin_project?(@project) — site admin OR membership with admin

Component-scoped ​

MethodChecks
authorize_viewer_componentcan_view_component?(@component) — site admin OR effective_permissions is any role
authorize_author_componentcan_author_component?(@component) — site admin OR effective_permissions author+ (blocked if released)
authorize_review_componentcan_review_component?(@component) — site admin OR effective_permissions reviewer+
authorize_admin_componentcan_admin_component?(@component) — site admin OR effective_permissions admin

Special ​

MethodChecks
authorize_component_accessViewer if unreleased, logged_in if released
authorize_compare_accessViewer on both components being compared
check_admin_for_advanced_fieldsAdmin required only when updating status/severity fields
authorize_membership_createAdmin on the target project or component
set_and_authorize_access_requestRequest owner or project admin

Controller Authorization Map ​

ProjectsController ​

ActionAuthorization
indexauthorize_logged_in
searchauthorize_logged_in
createauthorize_admin_or_create_permission_enabled
showauthorize_viewer_project
exportauthorize_viewer_project
updateauthorize_admin_project
destroyauthorize_admin_project

ComponentsController ​

ActionAuthorization
indexauthorize_logged_in
searchauthorize_logged_in
based_on_same_srgauthorize_logged_in
showauthorize_component_access (viewer if unreleased, logged_in if released)
exportauthorize_component_access
findauthorize_component_access
compareauthorize_compare_access (checks viewer on both components)
historyauthorize_viewer_project
createauthorize_admin_project
updateauthorize_author_component + check_admin_for_advanced_fields
destroyauthorize_admin_component

RulesController ​

ActionAuthorization
indexauthorize_viewer_component
showauthorize_viewer_component
related_rulesauthorize_viewer_component
searchauthorize_logged_in
createauthorize_author_component
updateauthorize_author_component
revertauthorize_author_component
destroyauthorize_admin_component

ReviewsController ​

ActionAuthorization
createauthorize_author_project
lock_controlsauthorize_admin_component

MembershipsController ​

ActionAuthorization
createauthorize_membership_create (admin on target project/component)
updateauthorize_admin_membership
destroyauthorize_admin_membership

ProjectAccessRequestsController ​

ActionAuthorization
createauthorize_logged_in
destroyset_and_authorize_access_request (owner or project admin)

SecurityRequirementsGuidesController ​

ActionAuthorization
indexauthorize_logged_in
showauthorize_logged_in
exportauthorize_logged_in
createauthorize_admin
destroyauthorize_admin

StigsController ​

ActionAuthorization
indexauthorize_logged_in
showauthorize_logged_in
exportauthorize_logged_in
createauthorize_admin
destroyauthorize_admin

UsersController ​

ActionAuthorization
indexauthorize_admin
updateauthorize_admin
destroyauthorize_admin

RuleSatisfactionsController ​

ActionAuthorization
createauthorize_author_component
destroyauthorize_author_component

Api::SearchController ​

ActionAuthorization
globalauthenticate_user! (data-scoped via current_user.available_projects)

Deny-by-Default Safety Net ​

spec/requests/authorization_coverage_spec.rb automatically verifies authorization coverage:

  1. Introspects the Rails route table to find all routable controller#action pairs
  2. For each, checks that at least one authorize_* before_action callback covers it
  3. Skips Devise controllers (they handle their own auth)
  4. Maintains a documented allowlist for actions that intentionally use only authenticate_user!

If you add a new controller action without an authorize_* callback, this test fails with a clear message telling you exactly which action is uncovered.

Adding a New Action ​

  1. Add the action to your controller
  2. Add an appropriate authorize_* before_action for it
  3. Run bundle exec rspec spec/requests/authorization_coverage_spec.rb
  4. If the test fails, it will tell you which action needs authorization

Rails Callback De-duplication Warning ​

Rails de-duplicates before_action callbacks with the same method name. If you write:

ruby
before_action :authorize_admin_component, only: %i[destroy]
before_action :authorize_admin_component, only: %i[update], if: -> { ... }

Only the LAST declaration survives. The destroy action will be unprotected. Use unique method names for callbacks that need different :only/:except/:if configurations:

ruby
before_action :authorize_admin_component, only: %i[destroy]
before_action :check_admin_for_advanced_fields, only: %i[update]

Error Handling ​

NotAuthorizedError is rescued globally in ApplicationController:

  • HTML requests: Flash alert + redirect back
  • JSON requests: 401 status with toast message
  • API requests (Api::BaseController): 403 Forbidden with JSON error

Part of the MITRE Security Automation Framework (SAF)