Skip to content
Open
10 changes: 9 additions & 1 deletion scripts/audit/no-hardcoded-role-checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ if ! command -v rg >/dev/null 2>&1; then
echo "Install with: brew install ripgrep (or: apt-get install ripgrep)" >&2
exit 2
fi
raw=$(rg -n -B1 -A1 '\brole\s*(===|!==)\s*"(admin|technician|member|guest)"' src \
raw=$(rg -n -B1 -A1 '\b[A-Za-z_$][A-Za-z0-9_$.]*\s*(===|!==)\s*"(admin|technician|member|guest)"' src \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match literal-first role comparisons

When a gate is written as "admin" === accessLevel (or the corresponding !== form), this regex does not match because it only accepts an identifier on the left and a role literal on the right; I exercised that input and the audit exited successfully. That leaves a straightforward hardcoded authorization gate invisible to the new CI check, so cover both operand orders and add a regression case for the reversed form. —Codex

AGENTS.md reference: AGENTS.md:L13-L15

Useful? React with 👍 / 👎.

--glob '!src/lib/permissions/matrix.ts' \
--glob '!src/lib/permissions/helpers.ts' \
--glob '!**/*.test.*' \
Expand Down Expand Up @@ -62,6 +62,14 @@ matches=$(echo "$raw" | awk '
if (parsed == "") next
n = split(parsed, parts, SUBSEP)
file = parts[1]; lineno = parts[2] + 0; sep = parts[3]; content = parts[4]

# Skip pure comment/JSDoc lines — they describe role checks, not perform them.
if (content ~ /^[[:space:]]*(\*|\/\/|\/\*)/) {
if (sep == ":") {
next
}
}

# Record allow markers at this (file, lineno).
if (content ~ /permissions-audit-allow:/) {
allow[file, lineno] = 1
Expand Down
65 changes: 65 additions & 0 deletions scripts/tests/test_no_hardcoded_role_checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from __future__ import annotations

import shutil
import subprocess
from pathlib import Path

import pytest

PROJECT_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = PROJECT_ROOT / "scripts/audit/no-hardcoded-role-checks.sh"
pytestmark = pytest.mark.skipif(
shutil.which("rg") is None,
reason="role-audit regression tests require the script's ripgrep prerequisite",
)


def run_audit(tmp_path: Path, source: str) -> subprocess.CompletedProcess[str]:
src = tmp_path / "src"
src.mkdir()
(src / "example.ts").write_text(source)
return subprocess.run(
["bash", str(SCRIPT)],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
)


def test_detects_arbitrary_identifiers_and_property_access(tmp_path: Path) -> None:
result = run_audit(
tmp_path,
'if (accessLevel === "admin" || currentUser.role !== "guest") {}\n',
)

assert result.returncode == 1
assert 'accessLevel === "admin"' in result.stderr


def test_ignores_role_comparisons_on_pure_comment_lines(tmp_path: Path) -> None:
result = run_audit(
tmp_path,
"""// accessLevel === \"admin\"
/* currentUser.role !== \"guest\" */
/**
* role === \"technician\"
*/
export const harmless = true;
""",
)

assert result.returncode == 0
assert result.stderr == ""


def test_accepts_an_adjacent_allow_marker(tmp_path: Path) -> None:
result = run_audit(
tmp_path,
"""// permissions-audit-allow: display-only label
if (viewer.role === \"member\") {}
""",
)

assert result.returncode == 0
assert result.stderr == ""
2 changes: 1 addition & 1 deletion src/app/(app)/admin/users/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export async function updateUserRole(
if (
validated.userType === "active" &&
validated.userId === user.id &&
validated.newRole !== "admin"
validated.newRole !== "admin" // permissions-audit-allow: self-demotion invariant
) {
throw new Error("Admins cannot demote themselves");
}
Expand Down
4 changes: 2 additions & 2 deletions src/app/(app)/admin/users/user-role-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function UserRoleSelect({
if (
userType === "active" &&
userId === currentUserId &&
newRole !== "admin"
newRole !== "admin" // permissions-audit-allow: self-demotion invariant
) {
toast.error("You cannot demote yourself.");
return;
Expand All @@ -58,7 +58,7 @@ export function UserRoleSelect({
defaultValue={currentRole}
onValueChange={handleRoleChange}
disabled={
isPending || (userId === currentUserId && currentRole === "admin")
isPending || (userId === currentUserId && currentRole === "admin") // permissions-audit-allow: self-demotion invariant
}
>
<SelectTrigger
Expand Down
3 changes: 1 addition & 2 deletions src/app/(app)/m/[initials]/(tabs)/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,8 +227,7 @@ export default async function MachineEditPage({
}))
);

const canEditAnyMachine =
accessLevel === "admin" || accessLevel === "technician";
const canEditAnyMachine = checkPermission("machines.edit", accessLevel);
const isOwner =
user.id === machine.ownerId || user.id === machine.invitedOwnerId;

Expand Down
35 changes: 18 additions & 17 deletions src/app/(app)/m/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
sortMachines,
} from "~/lib/machines/filters-queries";
import { MachineFilters } from "~/components/machines/MachineFilters";
import { getAccessLevel } from "~/lib/permissions/helpers";
import { checkPermission, getAccessLevel } from "~/lib/permissions/helpers";
import { formatDate } from "~/lib/dates";
import { PageContainer } from "~/components/layout/PageContainer";
import { PageHeader } from "~/components/layout/PageHeader";
Expand All @@ -40,7 +40,7 @@ interface MachinesPageProps {
* Status hierarchy: unplayable > needs_service > operational
*
* Accessible to all users (unauthenticated, guest, member, admin).
* The "Add Machine" button is only shown to admins.
* The "Add Machine" button follows the machines.create permission.
*/
export default async function MachinesPage({
searchParams,
Expand Down Expand Up @@ -150,19 +150,20 @@ export default async function MachinesPage({
filters.sort ?? "name_asc"
);

const addMachineButton =
accessLevel === "admin" || accessLevel === "technician" ? (
<Button
asChild
className="bg-primary text-on-primary hover:bg-primary/90"
data-testid="add-machine-button"
>
<Link href="/m/new">
<Plus className="mr-2 size-4" />
Add Machine
</Link>
</Button>
) : undefined;
const canCreateMachine = checkPermission("machines.create", accessLevel);

const addMachineButton = canCreateMachine ? (
<Button
asChild
className="bg-primary text-on-primary hover:bg-primary/90"
data-testid="add-machine-button"
>
<Link href="/m/new">
<Plus className="mr-2 size-4" />
Add Machine
</Link>
</Button>
) : undefined;

return (
<PageContainer size="wide">
Expand All @@ -189,12 +190,12 @@ export default async function MachinesPage({
icon={Plus}
title="No machines yet"
description={
accessLevel === "admin" || accessLevel === "technician"
canCreateMachine
? "Get started by adding your first machine to the collection."
: "No machines have been added to the collection yet."
}
action={
accessLevel === "admin" || accessLevel === "technician" ? (
canCreateMachine ? (
<Button
asChild
className="bg-primary text-on-primary hover:bg-primary/90"
Expand Down
66 changes: 37 additions & 29 deletions src/app/(app)/report/unified-report-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { StatusSelect } from "~/components/issues/fields/StatusSelect";
import { ImageUploadButton } from "~/components/images/ImageUploadButton";
import { ImageGallery } from "~/components/images/ImageGallery";
import type { AccessLevel } from "~/lib/permissions/matrix";
import { checkPermission } from "~/lib/permissions/helpers";
import { getLoginUrl } from "~/lib/login-url";
import { RecentIssuesPanelClient } from "~/components/issues/RecentIssuesPanelClient";
import { RichTextEditor } from "~/components/editor/RichTextEditorDynamic";
Expand Down Expand Up @@ -314,10 +315,9 @@ export function UnifiedReportForm({
};
}, [currentInitials]);

const canSetWorkflowFields =
accessLevel === "admin" ||
accessLevel === "technician" ||
accessLevel === "member";
const canSetStatus = checkPermission("issues.report.status", accessLevel);
const canSetPriority = checkPermission("issues.report.priority", accessLevel);
const canSetAssignee = checkPermission("issues.report.assignee", accessLevel);

return (
<div className="w-full">
Expand Down Expand Up @@ -491,36 +491,44 @@ export function UnifiedReportForm({
</div>
</div>

{/* Priority + Status: always side-by-side when visible */}
{canSetWorkflowFields && (
{/* Priority + Status: side-by-side when both are visible */}
{(canSetPriority || canSetStatus) && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="priority" className="text-foreground">
Priority *
</Label>
<input type="hidden" name="priority" value={entry.priority} />
<PrioritySelect
id="priority"
value={entry.priority}
onValueChange={(v) => patchEntry(0, { priority: v })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="status" className="text-foreground">
Status *
</Label>
<input type="hidden" name="status" value={entry.status} />
<StatusSelect
id="status"
value={entry.status}
onValueChange={(v) => patchEntry(0, { status: v })}
/>
</div>
{canSetPriority && (
<div className="space-y-1.5">
<Label htmlFor="priority" className="text-foreground">
Priority *
</Label>
<input
type="hidden"
name="priority"
value={entry.priority}
/>
<PrioritySelect
id="priority"
value={entry.priority}
onValueChange={(v) => patchEntry(0, { priority: v })}
/>
</div>
)}
{canSetStatus && (
<div className="space-y-1.5">
<Label htmlFor="status" className="text-foreground">
Status *
</Label>
<input type="hidden" name="status" value={entry.status} />
<StatusSelect
id="status"
value={entry.status}
onValueChange={(v) => patchEntry(0, { status: v })}
/>
</div>
)}
</div>
)}

{/* Assign To: full-width */}
{canSetWorkflowFields && assignees.length > 0 && (
{canSetAssignee && assignees.length > 0 && (
<div className="space-y-1.5">
<Label htmlFor="assignedTo" className="text-foreground">
Assign To
Expand Down
1 change: 1 addition & 0 deletions src/app/(auth)/oauth/consent/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ async function decideConsent(

const accessLevel = await getUserAccessLevel(user.id);
if (accessLevel !== "admin") {
// permissions-audit-allow: OAuth consent gate

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route OAuth consent through the permission matrix

This allow marker exempts an actual authorization gate: when the OAuth consent path is enabled, this branch decides whether the caller may approve or deny an authorization request. Under CORE-ARCH-008, allow annotations are only for non-gating comparisons; define an OAuth-consent capability in matrix.ts and use checkPermission() here and on the consent page so enforcement and the permissions matrix cannot drift. —Codex

AGENTS.md reference: AGENTS.md:L13-L15

Useful? React with 👍 / 👎.

// Non-admins can't authorize the MCP surface. Bounce back to the page,
// which renders the admin-only notice.
redirect(consentUrl(authorizationId));
Expand Down
1 change: 1 addition & 0 deletions src/app/(auth)/oauth/consent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export default async function OAuthConsentPage({

const accessLevel = await getUserAccessLevel(user.id);
if (accessLevel !== "admin") {
// permissions-audit-allow: OAuth consent gate
return (
<ConsentNotice
title="Admin access required"
Expand Down
12 changes: 8 additions & 4 deletions src/components/issues/IssueTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { type AccessLevel } from "~/lib/permissions/matrix";
import { checkPermission } from "~/lib/permissions/helpers";
import { RichTextDisplay } from "~/components/editor/RichTextDisplay";
import { RichTextEditor } from "~/components/editor/RichTextEditorDynamic";
import { type ProseMirrorDoc } from "~/lib/tiptap/types";
Expand Down Expand Up @@ -225,11 +226,14 @@ function TimelineItem({
// Edit: only the comment author can edit their own comments
const canEdit =
currentUserId === event.author.id && !event.isSystem && !isIssue;
// Delete: author can delete own comments, admins can delete any comment
// Delete: authors can delete their own comments; admins can delete any.
const canDeleteOwn = checkPermission("comments.delete", currentUserRole, {
userId: currentUserId ?? undefined,
reporterId: event.author.id,
});
const canDeleteAny = checkPermission("comments.delete.any", currentUserRole);
const canDelete =
(currentUserId === event.author.id || currentUserRole === "admin") &&
!event.isSystem &&
!isIssue;
(canDeleteOwn || canDeleteAny) && !event.isSystem && !isIssue;

const canShowActions = canEdit || canDelete;

Expand Down
6 changes: 3 additions & 3 deletions src/components/machines/OwnerSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,19 +210,19 @@ export function OwnerSelect({
{selectedUser ? (
<>
{selectedUser.name}
{selectedUser.role !== "guest" &&
{selectedUser.role !== "guest" && // permissions-audit-allow: UI display filter
selectedUser.status === "invited" && ( // permissions-audit-allow: UI badge display, not a permission gate
<span className="ml-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
(Invited)
</span>
)}
{selectedUser.role === "guest" &&
{selectedUser.role === "guest" && // permissions-audit-allow: UI display filter
selectedUser.status !== "invited" && ( // permissions-audit-allow: UI badge display, not a permission gate
<span className="ml-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
(GUEST)
</span>
)}
{selectedUser.role === "guest" &&
{selectedUser.role === "guest" && // permissions-audit-allow: UI display filter
selectedUser.status === "invited" && ( // permissions-audit-allow: UI badge display, not a permission gate
<span className="ml-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
(INVITED · GUEST)
Expand Down
8 changes: 4 additions & 4 deletions src/lib/machines/settings-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface SettingsSetAuth {
}

const isTechPlus = (access: AccessLevel): boolean =>
access === "technician" || access === "admin";
access === "technician" || access === "admin"; // permissions-audit-allow: per-set authorization matrix logic

const isMachineOwner = (
machineOwnerId: string | null,
Expand All @@ -42,7 +42,7 @@ export function canViewSet(
viewerId: string | null,
access: AccessLevel
): boolean {
if (set.isPublic || set.isPreferred || access === "admin") return true;
if (set.isPublic || set.isPreferred || access === "admin") return true; // permissions-audit-allow: per-set authorization matrix logic

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Centralize settings-set authorization

This annotation suppresses a role comparison that directly grants access to private settings sets, and the same exemption is added to the edit/default gates below. CORE-ARCH-008 requires resource predicates to live under src/lib/permissions/ and delegate their role dimension to checkPermission(); otherwise later matrix changes and the generated permissions help page can disagree with the live authorization behavior. Move these predicates to the centralized permissions module and model the admin/technician grants in the matrix rather than allowlisting them. —Codex

AGENTS.md reference: AGENTS.md:L13-L15

Useful? React with 👍 / 👎.

return set.createdById !== null && set.createdById === viewerId;
}

Expand All @@ -57,7 +57,7 @@ export function canEditSet(
access: AccessLevel
): boolean {
if (!canViewSet(set, viewerId, access)) return false;
if (access === "admin") return true;
if (access === "admin") return true; // permissions-audit-allow: per-set authorization matrix logic
if (isMachineOwner(machineOwnerId, viewerId)) return true;
// An owner set on a machine with NO owner has nobody to protect it for — the
// 0060 backfill turns every pre-existing preferred set into an owner set,
Expand All @@ -79,7 +79,7 @@ export function canSetOwnerDefault(
access: AccessLevel
): boolean {
if (!set.isOwnerSet) return false;
return access === "admin" || isMachineOwner(machineOwnerId, viewerId);
return access === "admin" || isMachineOwner(machineOwnerId, viewerId); // permissions-audit-allow: per-set authorization matrix logic
}

/** Publishing (public toggle) needs the same rights as editing. */
Expand Down
Loading