mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
fix(vault): enforce team access on document updates
This commit is contained in:
1 parent
59a1773a18
commit
94d7bbc28a
2 files changed
+71
No files matched your search
@@ -382,6 +382,13 @@ func (h *VaultHandler) handleUpdateDocument(w http.ResponseWriter, r *http.Reque
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
// Verify team boundary before any metadata or content rewrite. PUT can
|
||||
// materialize bytes on disk, so it must match GET/DELETE team access rules.
|
||||
if existing.TeamID != nil && *existing.TeamID != "" && !store.IsOwnerRole(r.Context()) {
|
||||
if !h.validateTeamMembership(r.Context(), w, *existing.TeamID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Title *string `json:"title"`
|
||||
|
||||
@@ -180,6 +180,26 @@ func (b *fakeEventBus) Subscribe(eventbus.EventType, eventbus.DomainEventHandler
|
||||
func (b *fakeEventBus) Start(context.Context) {}
|
||||
func (b *fakeEventBus) Drain(time.Duration) error { return nil }
|
||||
|
||||
type fakeTeamAccessStore struct {
|
||||
allowed bool
|
||||
}
|
||||
|
||||
func (f *fakeTeamAccessStore) GrantTeamAccess(context.Context, uuid.UUID, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeTeamAccessStore) RevokeTeamAccess(context.Context, uuid.UUID, string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeTeamAccessStore) ListTeamGrants(context.Context, uuid.UUID) ([]store.TeamUserGrant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeTeamAccessStore) ListUserTeams(context.Context, string) ([]store.TeamData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeTeamAccessStore) HasTeamAccess(context.Context, uuid.UUID, string) (bool, error) {
|
||||
return f.allowed, nil
|
||||
}
|
||||
|
||||
func newJSONRequest(t *testing.T, method, target string, body any) *http.Request {
|
||||
t.Helper()
|
||||
buf, err := json.Marshal(body)
|
||||
@@ -381,6 +401,50 @@ func TestHandleUpdateDocument_NilContentSkipsWriteAndEvent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpdateDocument_TeamDocRequiresMembershipBeforeContentWrite(t *testing.T) {
|
||||
teamID := uuid.NewString()
|
||||
st := newFakeVaultStore()
|
||||
st.docs["team-doc"] = &store.VaultDocument{
|
||||
ID: "team-doc",
|
||||
TenantID: masterTenant.String(),
|
||||
TeamID: &teamID,
|
||||
Path: "teams/" + teamID + "/private.md",
|
||||
Title: "Private",
|
||||
DocType: "note",
|
||||
Scope: "team",
|
||||
}
|
||||
bus := &fakeEventBus{store: st}
|
||||
ws := t.TempDir()
|
||||
h := &VaultHandler{
|
||||
store: st,
|
||||
teamAccess: &fakeTeamAccessStore{allowed: false},
|
||||
eventBus: bus,
|
||||
workspace: ws,
|
||||
}
|
||||
|
||||
ctx := store.WithUserID(masterCtx(), "non-member")
|
||||
req := newJSONRequest(t, http.MethodPut, "/v1/vault/documents/team-doc", map[string]any{
|
||||
"content": "should not be written",
|
||||
}).WithContext(ctx)
|
||||
req.SetPathValue("docID", "team-doc")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.handleUpdateDocument(rr, req)
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403 (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(ws, "teams", teamID, "private.md")); statErr == nil {
|
||||
t.Fatal("non-member content update wrote file before team access check")
|
||||
}
|
||||
if st.upsertCall != 0 {
|
||||
t.Fatalf("upsertCall = %d, want 0", st.upsertCall)
|
||||
}
|
||||
if len(bus.published) != 0 {
|
||||
t.Fatalf("published %d events, want 0", len(bus.published))
|
||||
}
|
||||
}
|
||||
|
||||
// Handler-level guard at vault_handler_documents.go path validation must reject
|
||||
// "..", short-circuiting before writeDocumentContent even runs.
|
||||
func TestHandleCreateDocument_RejectsParentTraversalPath(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user