Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 26 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ time-logging/
- `./TimeLogsSection` — rendered via the `task.detail.section` extension
point. Lets a member add/view/delete time-log entries for the current
task. The "log time" form and delete button both require
`viewer.can_write_tasks` (`tasks.write`), mirroring the routes' own
`viewer.can_write_tasks` (`tasks:write`), mirroring the routes' own
gate; delete is further limited by `canManageTimeLog` to the caller's
own entries, or any entry with `time_logging.manage_all`.
own entries, or any entry with `time_logging:manage_all`.
- `./ProjectTimeTrackingPage` — rendered via the `project.page` extension
point at its own routed page (`/projects/:projectId/plugins/com.paca.time-logging/time-tracking`),
reached through a nav item this plugin registers in the project sidebar.
Gated by the project-scoped `time_logging.view_all` custom permission —
Gated by the project-scoped `time_logging:view_all` custom permission —
the nav item is hidden for members without it. Lists every time-log
entry across every task in the project (date, member, task, note,
duration), filterable by member and date range, with inline edit/delete.
Expand All @@ -50,7 +50,7 @@ time-logging/
reached through a nav item this plugin registers in the admin sidebar.
Cross-project view: every time-log entry across every project in the
instance, filterable by project, user, and date range. Gated by the
plugin's own `time_logging.view_all` global custom permission, and
plugin's own `time_logging:view_all` global custom permission, and
deliberately read-only (see the doc comment on `AdminTimeTrackingPageInner`
for why editing doesn't belong here).
- Communicates with the backend through the standard plugin API path:
Expand All @@ -61,7 +61,7 @@ time-logging/
### Authorization

Logging, editing, and deleting time-log entries all require the built-in
`tasks.write` permission — tracking time against a task is treated as a
`tasks:write` permission — tracking time against a task is treated as a
task-editing action, same as the task itself. On top of that, a caller may
only edit or delete their **own** entries by default (`PATCH`/`DELETE` on
`/tasks/:taskId/time-logs/:logId` return `403` otherwise).
Expand All @@ -72,36 +72,41 @@ via the shared `canManageTimeLog` helper — but the backend re-checks both on
every write, so it remains the actual authorization boundary.

The plugin declares four custom permissions (`customPermissions` in
`plugin.json`) — note that both `time_logging.manage_all` and
`time_logging.view_all` are declared **twice**, once per scope, since the
`plugin.json`) — note that both `time_logging:manage_all` and
`time_logging:view_all` are declared **twice**, once per scope, since the
same key name reads naturally in both role editors and the two scopes never
share a permission map:

- `time_logging.manage_all` (scope `project`) — a project owner can grant
> The host is IAM-native: route gates (`requireActions` in `plugin.json`) and
> in-handler checks name IAM actions — `time_logging:manage_all`,
> `time_logging:view_all`, `tasks:write` — and roles grant those actions. The
> `customPermissions` keys below are those same action names.

- `time_logging:manage_all` (scope `project`) — a project owner can grant
this to a project role to let its holders edit/delete any project
member's entries within that project, in addition to their own.
- `time_logging.manage_all` (scope `global`) — grants the same
- `time_logging:manage_all` (scope `global`) — grants the same
edit/delete-any-entry capability, but instance-wide across every project,
not just one. It appears in the *global* role editor. Both variants are
checked identically via the SDK's `ctx.Permissions().Check(...)`, backed
by the host's `paca.permission_check` function, which merges a caller's
global and project permissions for project-scoped checks — so a global
grant satisfies the check in every project without needing a matching
per-project grant.
- `time_logging.view_all` (scope `project`) — required to reach the
- `time_logging:view_all` (scope `project`) — required to reach the
project's "Time Tracking" nav item/page and its `/time-logs` and
`/time-logs/summary` endpoints (the project-wide, every-member view — not
`/time-logs/me`, which stays open to any project member). It appears in
the *project* role editor and is enforced entirely by the host's
`requirePermissions` route middleware — no additional in-handler check.
`requireActions` route middleware — no additional in-handler check.
**Migration note:** before this permission existed, any project member
with baseline `projects.read` could see this view; existing project roles
with baseline `projects:read` could see this view; existing project roles
will need it granted explicitly to keep that access after upgrading.
- `time_logging.view_all` (scope `global`) — required to reach the
- `time_logging:view_all` (scope `global`) — required to reach the
admin-sidebar "Time Tracking" page and its `/time-logs/summary-all`,
`/time-logs/all`, and `/time-logs/users-all` endpoints. It appears in the
*global* role editor and is enforced entirely by the host's
`requirePermissions` route middleware (declared per-route in
`requireActions` route middleware (declared per-route in
`plugin.json`) — there is no additional in-handler check for it, since
those routes are otherwise fully public within the instance once granted.

Expand All @@ -123,17 +128,17 @@ authenticated member of the project.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/tasks/:taskId/time-logs` | List all time-log entries for a task (requires `tasks.read`) |
| `POST` | `/tasks/:taskId/time-logs` | Log time against a task (requires `tasks.write`) |
| `PATCH` | `/tasks/:taskId/time-logs/:logId` | Update a time-log entry (requires `tasks.write`, plus own entries or any entry with `time_logging.manage_all`) |
| `GET` | `/tasks/:taskId/time-logs` | List all time-log entries for a task (requires `tasks:read`) |
| `POST` | `/tasks/:taskId/time-logs` | Log time against a task (requires `tasks:write`) |
| `PATCH` | `/tasks/:taskId/time-logs/:logId` | Update a time-log entry (requires `tasks:write`, plus own entries or any entry with `time_logging:manage_all`) |
| `DELETE` | `/tasks/:taskId/time-logs/:logId` | Delete a time-log entry (same rule as `PATCH`) |
| `GET` | `/time-logs` | List every time-log entry across all tasks in the project (requires `time_logging.view_all`) |
| `GET` | `/time-logs/summary` | Total minutes logged per project member (requires `time_logging.view_all`) |
| `GET` | `/time-logs/me` | The caller's `member_id` and whether they hold `time_logging.manage_all` |
| `GET` | `/time-logs` | List every time-log entry across all tasks in the project (requires `time_logging:view_all`) |
| `GET` | `/time-logs/summary` | Total minutes logged per project member (requires `time_logging:view_all`) |
| `GET` | `/time-logs/me` | The caller's `member_id` and whether they hold `time_logging:manage_all` |

### Global/admin-scoped

No `:projectId` — gated by the plugin's own `time_logging.view_all` global
No `:projectId` — gated by the plugin's own `time_logging:view_all` global
custom permission.

| Method | Path | Description |
Expand Down
16 changes: 7 additions & 9 deletions backend/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,13 @@ func nowStr() string {
return time.Now().UTC().Format(time.RFC3339Nano)
}

// permManageAllTimeLogs is declared twice in plugin.json — once with
// scope "project" (grantable per project role) and once with scope
// "global" (grantable instance-wide) — sharing this one key. Either grant
// lets a caller create, edit, or delete time log entries attributed to other
// project members; a global grant applies in every project because
// paca.permission_check merges the caller's global and project permissions
// for project-scoped checks. Without either, a caller may only act on their
// own entries — see (*timeLoggingPlugin).canModify.
const permManageAllTimeLogs = "time_logging.manage_all"
// permManageAllTimeLogs is the IAM action letting a caller create, edit, or
// delete time log entries attributed to other project members. It is checked
// with paca.permission_check, which the host decides on project/<id> for a
// project-scoped request: a role allowing it in that project, or one
// allowing it everywhere ("project/*" or "*"), passes. Without it, a caller
// may only act on their own entries — see (*timeLoggingPlugin).canModify.
const permManageAllTimeLogs = "time_logging:manage_all"

// timeLoggingPlugin implements plugin.Plugin.
type timeLoggingPlugin struct {
Expand Down
8 changes: 4 additions & 4 deletions backend/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ func TestCreateTimeLog_OwnMemberID_AllowedWithoutManageAll(t *testing.T) {
}
}

// ── Ownership enforcement (time_logging.manage_all) ───────────────────────────
// ── Ownership enforcement (time_logging:manage_all) ───────────────────────────

func TestUpdateTimeLog_Forbidden_NotOwner(t *testing.T) {
tc := setupPlugin(t)
Expand Down Expand Up @@ -719,7 +719,7 @@ func TestDeleteTimeLog_ManageAll_AllowsOtherMembersEntries(t *testing.T) {
// updateTimeLogGlobal/deleteTimeLogGlobal perform no in-handler permission
// check of their own — like the other global-scope handlers in this file
// (listAllTimeLogs, timeLogsSummaryAll, timeLogsUsersAll), they rely entirely
// on the host's requirePermissions(scope=global, time_logging.manage_all)
// on the host's requireActions(scope=global, time_logging:manage_all)
// route gate, which plugintest.Context.Call does not simulate. These tests
// exercise the handlers' own DB logic, not that gate.

Expand Down Expand Up @@ -916,7 +916,7 @@ func TestViewer_DefaultNoWriteTasks(t *testing.T) {

func TestViewer_WriteTasksGranted(t *testing.T) {
tc := setupPlugin(t)
tc.Permissions.Grant("tasks.write")
tc.Permissions.Grant("tasks:write")

res := tc.Call("GET", "/time-logs/me", callerReq())
var env struct {
Expand All @@ -933,7 +933,7 @@ func TestViewer_WriteTasksGranted(t *testing.T) {
// ── Manifest / route-table parity ─────────────────────────────────────────────
//
// The host only enforces a route's declared plugin.json middlewares
// (optionalAuthn/requirePermissions/etc.) when the request path matches an
// (optionalAuthn/requireActions/etc.) when the request path matches an
// entry in plugin.json's backend.routes. If Init() registers a route here
// that has no matching manifest entry (or the manifest path is wrong), the
// host silently falls back to its weak default policy — optionalAuthn +
Expand Down
16 changes: 8 additions & 8 deletions backend/timelogs.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ type timeLogAllPage struct {
// project, returned by GET /time-logs/me. The frontend uses it to decide
// whether to offer the "log time" form and edit/delete controls at all
// (CanWriteTasks — logging/editing time entries is gated by the same
// tasks.write permission as the routes themselves) and, separately, whether
// tasks:write permission as the routes themselves) and, separately, whether
// an entry belonging to another member may be edited/deleted (CanManageAll).
type viewerInfo struct {
MemberID string `json:"member_id"`
Expand Down Expand Up @@ -168,7 +168,7 @@ func (s *scanner) intVal(col string) int {
func (p *timeLoggingPlugin) viewer(req *plugin.Request, res *plugin.Response) {
ok(res, viewerInfo{
MemberID: req.Caller.CallerID,
CanWriteTasks: p.perm.Check("tasks.write"),
CanWriteTasks: p.perm.Check("tasks:write"),
CanManageAll: p.perm.Check(permManageAllTimeLogs),
})
}
Expand All @@ -182,10 +182,10 @@ func (p *timeLoggingPlugin) viewerAll(req *plugin.Request, res *plugin.Response)

// listTimeLogs handles GET /tasks/:taskId/time-logs
// Returns all time-log entries for the task (every member's), most recent
// spent_date first. Gated by tasks.read only, not time_logging.view_all —
// spent_date first. Gated by tasks:read only, not time_logging:view_all —
// intentionally: per-task time logs are part of that task's own visible
// history (like comments or activity), open to anyone who can already open
// the task. time_logging.view_all instead gates project-wide rollups
// the task. time_logging:view_all instead gates project-wide rollups
// (listProjectTimeLogs, timeLogsSummary) where the concern is aggregate
// reporting across a whole project, not a single task the caller can already
// see.
Expand All @@ -209,7 +209,7 @@ func (p *timeLoggingPlugin) listTimeLogs(req *plugin.Request, res *plugin.Respon
// createTimeLog handles POST /tasks/:taskId/time-logs
// Body: { "spent_date": "2026-07-13", "minutes_spent": 90, "note": "..." (optional) }
// member_id defaults to the caller unless explicitly provided, in which case
// the caller must hold time_logging.manage_all to attribute the entry to
// the caller must hold time_logging:manage_all to attribute the entry to
// someone else — see canModify. Without it, a caller could otherwise
// fabricate time entries under another member's name, which is exactly the
// capability manage_all is meant to gate (update/delete already require it
Expand Down Expand Up @@ -248,7 +248,7 @@ func (p *timeLoggingPlugin) createTimeLog(req *plugin.Request, res *plugin.Respo
return
}
if !p.canModify(req, *b.MemberID) {
res.Error(403, "you can only log time for yourself unless you hold time_logging.manage_all")
res.Error(403, "you can only log time for yourself unless you hold time_logging:manage_all")
return
}
memberID = *b.MemberID
Expand Down Expand Up @@ -452,7 +452,7 @@ func (p *timeLoggingPlugin) deleteTimeLog(req *plugin.Request, res *plugin.Respo
// with no :projectId/:taskId in the path — unlike updateTimeLog/deleteTimeLog,
// there's no single project to scope the route to, and the caller may not
// even be a member of the entry's project. The host route gate requires the
// global-scope time_logging.manage_all permission (see plugin.json), which
// global-scope time_logging:manage_all permission (see plugin.json), which
// per its own declared description already grants "edit or delete any
// project member's time log entries, across every project" — so unlike
// canModify's self-or-manage_all check, no additional per-row ownership test
Expand Down Expand Up @@ -1062,7 +1062,7 @@ func (p *timeLoggingPlugin) handleTaskDeleted(evt *plugin.Event) {

// canModify reports whether the caller may act on a time log entry owned by
// memberID: either it's their own entry, or they hold the
// time_logging.manage_all custom permission. Used both to gate edit/delete of
// time_logging:manage_all custom permission. Used both to gate edit/delete of
// an existing entry and, in createTimeLog, to gate attributing a new entry to
// someone other than the caller.
func (p *timeLoggingPlugin) canModify(req *plugin.Request, memberID string) bool {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/AdminTimeTrackingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import TimeTrackingPage from "./TimeTrackingPage";
/**
* AdminTimeTrackingPage — the `admin.page` entry component exposed by the
* time-logging plugin, reached via a dedicated nav item in the admin
* sidebar; gated by the plugin's own `time_logging.view_all` global custom
* sidebar; gated by the plugin's own `time_logging:view_all` global custom
* permission (declared in plugin.json), not the built-in `users.write`.
* Thin wrapper: the actual list/table/filter/edit UI lives in the shared
* TimeTrackingPage component, also used by ProjectTimeTrackingPage.
*
* Edit/delete controls appear here only for callers who additionally hold
* the global `time_logging.manage_all` permission (see useTimeLogViewerAll),
* the global `time_logging:manage_all` permission (see useTimeLogViewerAll),
* backed by the dedicated global-scope PATCH/DELETE /time-logs/all/:logId
* routes — a global grant applies across every project regardless of the
* caller's membership in any one of them, unlike the project-scoped page's
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/TimeLogsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ interface TimeLogsSectionProps {
* (date + duration + optional note) against the current task and shows the
* running total for the task.
*
* The "log time" form and delete button both require `tasks.write`
* The "log time" form and delete button both require `tasks:write`
* (`viewer.can_write_tasks`), matching the routes' own gate. Delete is
* further restricted to own entries, or any entry with
* `time_logging.manage_all`, via `canManageTimeLog`; the backend re-checks
* `time_logging:manage_all`, via `canManageTimeLog`; the backend re-checks
* both on every write, so it's the actual authorization boundary, not this
* component's rendering.
*/
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/TimeTrackingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,12 @@ interface DisplayRow {
*
* Project scope: lists one project's entries (GET .../time-logs), filterable
* by member + date range; edit/delete gated per-row by canManageTimeLog (own
* entry, or the project/global time_logging.manage_all permission), against
* entry, or the project/global time_logging:manage_all permission), against
* the project-scoped PATCH/DELETE .../tasks/:taskId/time-logs/:logId routes.
*
* Admin scope: lists every project's entries (GET /time-logs/all), filterable
* by project + user + date range; edit/delete gated uniformly — not per-row —
* by whether the caller holds the global time_logging.manage_all permission
* by whether the caller holds the global time_logging:manage_all permission
* (useTimeLogViewerAll), against the dedicated global-scope PATCH/DELETE
* /time-logs/all/:logId routes. There's no per-row ownership check at this
* scope: the caller may not even be a member of a given entry's project, so
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,11 @@ export const PAGE_SIZES = [10, 25, 50] as const;

/**
* Fetches the current caller's project_members id and permission state for
* this project (tasks.write, time_logging.manage_all). List views use this
* this project (tasks:write, time_logging:manage_all). List views use this
* to decide whether to offer the "log time" form and edit/delete controls
* at all (`can_write_tasks`), and whether an entry belonging to another
* member may be edited/deleted (`can_manage_all`) vs. only the caller's own.
* This is a UX affordance only: the backend re-checks tasks.write and
* This is a UX affordance only: the backend re-checks tasks:write and
* ownership/manage_all on every write, so it remains the actual
* authorization boundary.
*
Expand All @@ -131,7 +131,7 @@ export function useTimeLogViewer(
}

/**
* Fetches the current caller's global time_logging.manage_all permission
* Fetches the current caller's global time_logging:manage_all permission
* state for the admin (cross-project) time tracking page. Unlike
* useTimeLogViewer there's no per-project ownership to layer on top — a
* global manage_all grant applies uniformly to every row on that page.
Expand All @@ -154,7 +154,7 @@ export function useTimeLogViewerAll(

/**
* Whether the viewer may edit/delete a time log entry owned by memberId.
* Requires `can_write_tasks` (mirroring the routes' own tasks.write gate)
* Requires `can_write_tasks` (mirroring the routes' own tasks:write gate)
* plus either ownership of the entry or `can_manage_all`.
*/
export function canManageTimeLog(
Expand Down
Loading
Loading