Skip to content

fix(time): don't inject default project on issue-scoped time list/log (#123)#124

Merged
aarondpn merged 1 commit into
mainfrom
fix/time-default-project-issue-scope-123
Jun 19, 2026
Merged

fix(time): don't inject default project on issue-scoped time list/log (#123)#124
aarondpn merged 1 commit into
mainfrom
fix/time-default-project-issue-scope-123

Conversation

@aarondpn

Copy link
Copy Markdown
Owner

Summary

redmine time list --issue <id> and redmine time log --issue <id> returned Permission denied (HTTP 403) while the equivalent raw call redmine api /time_entries.json -f issue_id=<id> ... worked with the same API key. Closes #123.

Root cause

Both commands applied the configured default project unconditionally before building the request, injecting an unrelated project_id that the raw API call never sends:

Command Request actually sent
api /time_entries.json -f issue_id=42 -f limit=100 (works) GET …?issue_id=42&limit=100
time list --issue 42 (default project set) GET …?issue_id=42&limit=100&offset=0&project_id=7
time log --issue 42 (default project set) POST body {"issue_id":42,"project_id":"7",…}

Reproduced against a logging mock server, and the 403 was confirmed against Redmine's own source:

  • List (TimelogController#index -> find_optional_project): authorization keys on project_id, not issue_id. User#allowed_to?(action, project) runs return false unless context.allows_to?(action) (module-enabled check) before return true if admin?, so a project_id whose project has time tracking disabled returns 403 even for an admin. With only issue_id, the :global branch grants admins immediately.
  • Log (TimelogController#create): TimeEntry#safe_attributes= only corrects the project to the issue's project when project_id is blank. A non-blank injected project_id keeps the wrong project, then create does render_403 on the log_time check (or fails validation with an issue/project mismatch).

The reported "activity inactive at project level" detail is a red herring: that would surface as a 422, and time list never touches activities.

Fix

Gate the default-project fallback on issue == 0. When an issue scopes the request, the issue determines the project, so no project_id is injected; an explicit --project is still resolved and honored. Commands without an issue scope keep the default-project fallback.

Scope check

A multi-agent audit of every default-project callsite plus the ops/MCP layers confirmed time list and time log are the only members of this bug class. time summary was considered and deliberately left unchanged: it has no issue scope, so honoring the configured default project is intended (matching the no-issue branch of list/log); changing it would regress project-scoped summaries. The ops/MCP layers never inject a default project, so they are unaffected.

Tests

  • Unit (internal/cmd/time/time_test.go):
    • TestTimeList_IssueScopeIgnoresDefaultProject and TestTimeLog_IssueScopeIgnoresDefaultProject assert no project_id leaks when scoping by issue with a default project configured.
    • TestTimeSummary_HonorsDefaultProject pins the deliberate decision that summary still honors the default project.
  • E2E (e2e/time_entries_test.go): TestTimeEntries_DefaultProjectIgnoredForIssueScope configures a default project that differs from the issue's project and verifies time log attaches to the issue's project and time list --issue returns the entry.

Verification

  • gofmt, go vet ./... (and -tags e2e), golangci-lint run ./... (0 issues)
  • Full unit suite passes; e2e compiles under the e2e tag.

`time list --issue` and `time log --issue` applied the configured default
project unconditionally, sending an unrelated project_id alongside the
issue. Redmine authorizes /time_entries against that project_id and returns
403 when its time-tracking module is disabled (the admin bypass runs after
the module check), or rejects the create on an issue/project mismatch. The
equivalent raw API call, which sends only issue_id, worked, which is why the
failure looked like a CLI-only permission bug.

Gate the default-project fallback on issue == 0 so issue-scoped requests
carry no injected project_id; an explicit --project is still resolved and
honored. Project-scoped commands without an issue (including time summary)
keep the default-project fallback, which is intended behavior.

Adds unit regression tests for list, log, and summary, plus an e2e test
covering a default project that differs from the issue's project.

Closes #123
@aarondpn aarondpn merged commit 47f6330 into main Jun 19, 2026
6 checks passed
@aarondpn aarondpn deleted the fix/time-default-project-issue-scope-123 branch June 19, 2026 23:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

time list / time log return Permission denied while equivalent raw API calls work

1 participant