IAM U1 + U3A capability-association + capability-explanation #219
@@ -371,6 +371,12 @@ func rolePolicySnapshotForUser(st *store.Store, userID string) (extension.RolePo
|
||||
snapshot := extension.RolePolicySnapshot{SchemaVersion: extension.RolePolicySchemaVersion,
|
||||
UserID: account.ID, Owner: account.Owner, AccountEnabled: account.Enabled,
|
||||
AuthorityEpoch: epoch, Permissions: map[string]extension.RolePermission{}}
|
||||
linked, err := authzsvc.NewService(st).EffectiveLinkedCapabilities(account.Owner)
|
||||
if err != nil {
|
||||
return extension.RolePolicySnapshot{}, err
|
||||
}
|
||||
snapshot.Linked = extension.LinkedCapabilities{ProjectCreate: linked.ProjectCreate, Source: linked.Source,
|
||||
Actions: linked.LinkedActions, Conflicts: linked.Conflicts}
|
||||
view, err := st.RoleForUser(userID)
|
||||
if err != nil {
|
||||
return extension.RolePolicySnapshot{}, err
|
||||
@@ -456,6 +462,64 @@ func (h inprocHost) CheckCurrentSystemAction(ctx context.Context, actionID strin
|
||||
AuthorityEpoch: decision.AuthorityEpoch, Reason: decision.Reason}, nil
|
||||
}
|
||||
|
||||
func (h inprocHost) currentCapabilityExplanation(ctx context.Context) (store.CapabilityExplanation, extension.CapabilitySubject, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
|
||||
}
|
||||
if _, err := h.rolePolicyActor(); err != nil {
|
||||
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
|
||||
}
|
||||
explanation, err := authzsvc.NewService(h.caps.st).ExplainCapabilities(h.actor)
|
||||
if err != nil {
|
||||
return store.CapabilityExplanation{}, extension.CapabilitySubject{}, err
|
||||
}
|
||||
subject := extension.CapabilitySubject{SchemaVersion: extension.CapabilityExplainSchemaVersion,
|
||||
UserID: explanation.UserID, Owner: explanation.Owner, AuthorityEpoch: explanation.AuthorityEpoch,
|
||||
RoleID: explanation.RoleID, RoleName: explanation.RoleName, RoleEnabled: explanation.RoleEnabled}
|
||||
return explanation, subject, nil
|
||||
}
|
||||
|
||||
func capabilityActionExplains(in []store.CapabilityActionExplain) []extension.CapabilityActionExplain {
|
||||
out := make([]extension.CapabilityActionExplain, 0, len(in))
|
||||
for _, action := range in {
|
||||
out = append(out, extension.CapabilityActionExplain(action))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h inprocHost) CurrentCapabilityExplain(ctx context.Context) (extension.CapabilityExplain, error) {
|
||||
explanation, subject, err := h.currentCapabilityExplanation(ctx)
|
||||
if err != nil {
|
||||
return extension.CapabilityExplain{}, err
|
||||
}
|
||||
capability := explanation.Capability
|
||||
return extension.CapabilityExplain{CapabilitySubject: subject,
|
||||
ProjectCreate: extension.CapabilityActionExplain(capability.ProjectCreate),
|
||||
Linked: capabilityActionExplains(capability.Linked),
|
||||
Conflicts: append([]string{}, capability.Conflicts...), MigrationNote: capability.MigrationNote}, nil
|
||||
}
|
||||
|
||||
func (h inprocHost) CurrentMemberBasicCollaborate(ctx context.Context) (extension.MemberBasicCollaborate, error) {
|
||||
explanation, subject, err := h.currentCapabilityExplanation(ctx)
|
||||
if err != nil {
|
||||
return extension.MemberBasicCollaborate{}, err
|
||||
}
|
||||
return extension.MemberBasicCollaborate{CapabilitySubject: subject,
|
||||
Actions: capabilityActionExplains(explanation.MemberBasicCollaborate.Actions)}, nil
|
||||
}
|
||||
|
||||
func (h inprocHost) CurrentDocumentQualify(ctx context.Context) (extension.DocumentQualify, error) {
|
||||
explanation, subject, err := h.currentCapabilityExplanation(ctx)
|
||||
if err != nil {
|
||||
return extension.DocumentQualify{}, err
|
||||
}
|
||||
duties := make([]extension.DocumentDutyQualify, 0, len(explanation.DocumentQualify.Duties))
|
||||
for _, duty := range explanation.DocumentQualify.Duties {
|
||||
duties = append(duties, extension.DocumentDutyQualify(duty))
|
||||
}
|
||||
return extension.DocumentQualify{CapabilitySubject: subject, Duties: duties}, nil
|
||||
}
|
||||
|
||||
func (h inprocHost) requireRoleAdminAction(actionID string) error {
|
||||
if h.actor == "" || h.owner != h.actor {
|
||||
return store.ErrAccountForbidden
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
func TestCapabilityExplainProviderExplainsBoundActorOnly(t *testing.T) {
|
||||
st, err := store.OpenSQLite(t.TempDir() + "/capability-explain.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
if err := st.CreateAccount("admin", "Admin", "hash", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.CreateAccount("alice", "Alice", "hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alice, _ := st.GetAccountByOwner("alice")
|
||||
role, err := st.CreateRole("admin", store.RoleCreate{Name: "项目负责人"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []store.RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "document.read", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "document.review", Allowed: false, CapAllowed: true},
|
||||
}, "explain-provider"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-provider-assign"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice", actor: "alice"}
|
||||
provider, ok := any(h).(extension.CapabilityExplainProvider)
|
||||
if !ok {
|
||||
t.Fatal("dispatch host missing CapabilityExplainProvider")
|
||||
}
|
||||
ctx := context.Background()
|
||||
capability, err := provider.CurrentCapabilityExplain(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if capability.SchemaVersion != extension.CapabilityExplainSchemaVersion || capability.UserID != alice.ID || capability.Owner != "alice" ||
|
||||
capability.RoleID != role.RoleID || !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != extension.CapabilitySourceRole ||
|
||||
len(capability.Linked) != 4 || len(capability.Conflicts) != 0 {
|
||||
t.Fatalf("capability=%+v", capability)
|
||||
}
|
||||
snapshot, err := any(h).(extension.RolePolicyProvider).CurrentRolePolicy(ctx)
|
||||
if err != nil || snapshot.Linked.Source != capability.ProjectCreate.Source || len(snapshot.Linked.Actions) != len(capability.Linked) {
|
||||
t.Fatalf("snapshot linked=%+v err=%v", snapshot.Linked, err)
|
||||
}
|
||||
member, err := provider.CurrentMemberBasicCollaborate(ctx)
|
||||
if err != nil || member.UserID != alice.ID || len(member.Actions) == 0 {
|
||||
t.Fatalf("member=%+v err=%v", member, err)
|
||||
}
|
||||
for _, action := range member.Actions {
|
||||
if action.ActionID == "document.read" && (!action.Allowed || action.Source != extension.CapabilitySourceRole) {
|
||||
t.Fatalf("document.read=%+v", action)
|
||||
}
|
||||
}
|
||||
document, err := provider.CurrentDocumentQualify(ctx)
|
||||
if err != nil || document.UserID != alice.ID || len(document.Duties) != 3 {
|
||||
t.Fatalf("document=%+v err=%v", document, err)
|
||||
}
|
||||
for _, duty := range document.Duties {
|
||||
if duty.ActionID == "document.review" && (!duty.Qualified || duty.DefaultAllowed) {
|
||||
t.Fatalf("document.review=%+v", duty)
|
||||
}
|
||||
}
|
||||
|
||||
// A scoped Host without a dispatch actor cannot explain anyone.
|
||||
scoped := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "alice"}).(extension.CapabilityExplainProvider)
|
||||
if _, err := scoped.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("scoped capability: %v", err)
|
||||
}
|
||||
if _, err := scoped.CurrentMemberBasicCollaborate(ctx); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("scoped member: %v", err)
|
||||
}
|
||||
if _, err := scoped.CurrentDocumentQualify(ctx); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("scoped document: %v", err)
|
||||
}
|
||||
ghost := any(inprocHost{caps: hostcaps{st: st}, pluginID: "rdm", owner: "ghost", actor: "ghost"}).(extension.CapabilityExplainProvider)
|
||||
if _, err := ghost.CurrentCapabilityExplain(ctx); !errors.Is(err, store.ErrAccountForbidden) {
|
||||
t.Fatalf("unknown actor: %v", err)
|
||||
}
|
||||
canceled, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
if _, err := provider.CurrentCapabilityExplain(canceled); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -65,3 +65,29 @@ func Catalog() []Descriptor {
|
||||
copy(out, descriptors)
|
||||
return out
|
||||
}
|
||||
|
||||
// ProjectCreateAction is the system action whose holder manages the projects
|
||||
// they create. Enabling it links ProjectCreateLinkedActions for those projects.
|
||||
const ProjectCreateAction = "project.create"
|
||||
|
||||
var projectCreateLinkedActions = []string{
|
||||
"project.settings",
|
||||
"project.members.manage",
|
||||
"project.permissions.manage",
|
||||
"document.duties.assign",
|
||||
}
|
||||
|
||||
// ProjectCreateLinkedActions returns the own-project management capabilities
|
||||
// that a project creator receives together with project.create.
|
||||
func ProjectCreateLinkedActions() []string {
|
||||
return append([]string(nil), projectCreateLinkedActions...)
|
||||
}
|
||||
|
||||
func IsProjectCreateLinkedAction(id string) bool {
|
||||
for _, linked := range projectCreateLinkedActions {
|
||||
if linked == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -16,6 +16,26 @@ func TestCatalogRejectsUnknownAndProtectsManagementActions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCreateLinkedActionsAreCatalogedProjectActions(t *testing.T) {
|
||||
linked := ProjectCreateLinkedActions()
|
||||
if len(linked) != 4 {
|
||||
t.Fatalf("linked=%v", linked)
|
||||
}
|
||||
for _, actionID := range linked {
|
||||
descriptor, ok := DescriptorByID(actionID)
|
||||
if !ok || descriptor.Scope != ScopeProject || descriptor.Protected || !IsProjectCreateLinkedAction(actionID) {
|
||||
t.Fatalf("linked action %s descriptor=%+v ok=%v", actionID, descriptor, ok)
|
||||
}
|
||||
}
|
||||
if IsProjectCreateLinkedAction(ProjectCreateAction) || IsProjectCreateLinkedAction("document.read") {
|
||||
t.Fatal("unlinked action reported as linked")
|
||||
}
|
||||
linked[0] = "mutated"
|
||||
if ProjectCreateLinkedActions()[0] == "mutated" {
|
||||
t.Fatal("linked actions exposed mutable storage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogReturnsIndependentCopies(t *testing.T) {
|
||||
first := Catalog()
|
||||
if len(first) == 0 {
|
||||
|
||||
@@ -77,6 +77,25 @@ func (s *Service) AuthorizeSystem(actor, actionID string) Decision {
|
||||
return decision
|
||||
}
|
||||
|
||||
// EffectiveLinkedCapabilities returns the own-project management actions that
|
||||
// the actor's project.create links. HTTP and command readers share this call
|
||||
// so the link is computed once per actor from the Backend authority.
|
||||
func (s *Service) EffectiveLinkedCapabilities(actor string) (store.CapabilityAssociation, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return store.CapabilityAssociation{LinkedActions: []string{}}, store.ErrAccountForbidden
|
||||
}
|
||||
return s.store.LinkedCapabilities(actor)
|
||||
}
|
||||
|
||||
// ExplainCapabilities explains only the actor's own creator, member and
|
||||
// document-duty capabilities; HTTP and Host providers share this call.
|
||||
func (s *Service) ExplainCapabilities(actor string) (store.CapabilityExplanation, error) {
|
||||
if s == nil || s.store == nil {
|
||||
return store.CapabilityExplanation{}, store.ErrAccountForbidden
|
||||
}
|
||||
return s.store.ExplainCapabilities(actor)
|
||||
}
|
||||
|
||||
func (s *Service) EffectiveSystemActions(actor string) []string {
|
||||
actions := []string{}
|
||||
for _, descriptor := range catalog.Catalog() {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
// explainTargetParams would select another actor; the endpoint refuses them
|
||||
// outright so the response never depends on whether that actor exists.
|
||||
var explainTargetParams = []string{"owner", "userId", "user", "actor"}
|
||||
|
||||
func (s *Server) explainCapability(w http.ResponseWriter, r *http.Request) {
|
||||
if SessionScope(r) == store.SessionScopeSetPasswordOnly {
|
||||
http.Error(w, "capability explain denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
for _, key := range explainTargetParams {
|
||||
if query.Has(key) {
|
||||
http.Error(w, "capability explain is limited to the current actor", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
explanation, err := s.authorizer.ExplainCapabilities(s.owner(r))
|
||||
if errors.Is(err, store.ErrAccountForbidden) {
|
||||
http.Error(w, "capability explain denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "capability explain unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, explanation)
|
||||
}
|
||||
|
||||
// categorizeWorkFailure maps Backend and provider errors for operation into
|
||||
// one WorkFailure category. Uncategorized errors, notably not-found, keep the
|
||||
// existing handling so a refusal never reveals another project or account.
|
||||
func categorizeWorkFailure(operation string, err error) (extension.WorkFailure, bool) {
|
||||
var failure extension.WorkFailure
|
||||
switch {
|
||||
case err == nil:
|
||||
return extension.WorkFailure{}, false
|
||||
case errors.As(err, &failure) && failure.StatusCode() != 0:
|
||||
return extension.CategorizeWorkFailure(operation, err)
|
||||
case errors.Is(err, store.ErrAccountForbidden):
|
||||
return extension.WorkFailure{Operation: operation, Reason: extension.WorkFailurePolicyRejected}, true
|
||||
case operation != extension.WorkOperationConversation && errors.Is(err, store.ErrConflict):
|
||||
return extension.WorkFailure{Operation: operation, Reason: extension.WorkFailureDuplicateName}, true
|
||||
case errors.Is(err, sql.ErrConnDone), errors.Is(err, driver.ErrBadConn):
|
||||
return extension.WorkFailure{Operation: operation, Reason: extension.WorkFailureServiceUnavailable}, true
|
||||
}
|
||||
return extension.CategorizeWorkFailure(operation, err)
|
||||
}
|
||||
|
||||
func writeWorkFailure(w http.ResponseWriter, operation string, err error) {
|
||||
if failure, ok := categorizeWorkFailure(operation, err); ok {
|
||||
http.Error(w, failure.Error(), failure.StatusCode())
|
||||
return
|
||||
}
|
||||
writeProjectErr(w, err)
|
||||
}
|
||||
|
||||
func writeWorkJSON(w http.ResponseWriter, operation string, v any, err error) {
|
||||
if err != nil {
|
||||
writeWorkFailure(w, operation, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// leakyStatusError carries provider detail that must never reach a client.
|
||||
type leakyStatusError int
|
||||
|
||||
func (e leakyStatusError) Error() string { return "secret-project of other-owner already exists" }
|
||||
func (e leakyStatusError) StatusCode() int { return int(e) }
|
||||
|
||||
const leakMarker = "other-owner"
|
||||
|
||||
type capabilityExplainHTTP struct {
|
||||
UserID string `json:"userId"`
|
||||
Owner string `json:"owner"`
|
||||
RoleID string `json:"roleId"`
|
||||
Capability struct {
|
||||
ProjectCreate extension.CapabilityActionExplain `json:"projectCreate"`
|
||||
Linked []extension.CapabilityActionExplain `json:"linked"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
MigrationNote string `json:"migrationNote"`
|
||||
} `json:"capability"`
|
||||
MemberBasicCollaborate struct {
|
||||
Actions []extension.CapabilityActionExplain `json:"actions"`
|
||||
} `json:"memberBasicCollaborate"`
|
||||
DocumentQualify struct {
|
||||
Duties []extension.DocumentDutyQualify `json:"duties"`
|
||||
} `json:"documentQualify"`
|
||||
}
|
||||
|
||||
func explainRoleFixture(t *testing.T, h http.Handler, st *store.Store, admin, owner, name, changes string) (store.Account, string) {
|
||||
t.Helper()
|
||||
if err := st.CreateAccount(owner, owner, "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account, _ := st.GetAccountByOwner(owner)
|
||||
key, err := st.CreateAPIKey(owner, owner+"-explain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"`+name+`"}`, admin)
|
||||
if roleRec.Code != http.StatusOK {
|
||||
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
|
||||
}
|
||||
role := decodeRoleHTTP(t, roleRec)
|
||||
policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
|
||||
`{"expectedRoleVersion":1,"idempotencyKey":"policy-`+owner+`","changes":`+changes+`}`, admin)
|
||||
if policy.Code != http.StatusOK {
|
||||
t.Fatalf("policy: %d %s", policy.Code, policy.Body.String())
|
||||
}
|
||||
assign := do(h, "PUT", "/api/admin/users/"+account.ID+"/role",
|
||||
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(account.StateVersion, 10)+`,"idempotencyKey":"assign-`+owner+`"}`, admin)
|
||||
if assign.Code != http.StatusOK {
|
||||
t.Fatalf("assign: %d %s", assign.Code, assign.Body.String())
|
||||
}
|
||||
return account, key
|
||||
}
|
||||
|
||||
func decodeCapabilityExplain(t *testing.T, rec *httptest.ResponseRecorder) capabilityExplainHTTP {
|
||||
t.Helper()
|
||||
var out capabilityExplainHTTP
|
||||
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil {
|
||||
t.Fatalf("explain: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCapabilityExplainHTTPReturnsCreatorSourceAndLinkedActions(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
creator, key := explainRoleFixture(t, h, st, admin, "creator", "项目创建者",
|
||||
`[{"actionId":"project.create","allowed":true,"capAllowed":true}]`)
|
||||
rec := bearerRequest(h, "GET", "/api/capability/explain", "", key)
|
||||
body := decodeCapabilityExplain(t, rec)
|
||||
if body.Owner != "creator" || body.UserID != creator.ID || body.RoleID == "" {
|
||||
t.Fatalf("subject=%+v", body)
|
||||
}
|
||||
create := body.Capability.ProjectCreate
|
||||
if !create.Allowed || create.Source != extension.CapabilitySourceRole || create.Scope != extension.CapabilityScopeSystem {
|
||||
t.Fatalf("project.create=%+v", create)
|
||||
}
|
||||
linked := []string{}
|
||||
for _, action := range body.Capability.Linked {
|
||||
if !action.Allowed || action.Source != "role" || action.LinkedVia != "project.create" || action.Scope != "own-projects" {
|
||||
t.Fatalf("linked=%+v", action)
|
||||
}
|
||||
linked = append(linked, action.ActionID)
|
||||
}
|
||||
if strings.Join(linked, ",") != "project.settings,project.members.manage,project.permissions.manage,document.duties.assign" ||
|
||||
len(body.Capability.Conflicts) != 0 || body.Capability.MigrationNote != "" {
|
||||
t.Fatalf("capability=%+v", body.Capability)
|
||||
}
|
||||
// The /api/me linked view and the explanation agree on one snapshot.
|
||||
me := bearerRequest(h, "GET", "/api/me", "", key)
|
||||
if !strings.Contains(me.Body.String(), `"source":"role"`) {
|
||||
t.Fatalf("me: %s", me.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), `"admin"`) {
|
||||
t.Fatalf("explanation named another account: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberBasicCollaborateAndDocumentQualifyOverHTTP(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
_, key := explainRoleFixture(t, h, st, admin, "member", "协作成员", `[
|
||||
{"actionId":"document.read","allowed":true,"capAllowed":true},
|
||||
{"actionId":"trace.edit","allowed":false,"capAllowed":true},
|
||||
{"actionId":"document.author","allowed":true,"capAllowed":true},
|
||||
{"actionId":"document.approve","allowed":false,"capAllowed":false}]`)
|
||||
body := decodeCapabilityExplain(t, bearerRequest(h, "GET", "/api/capability/explain", "", key))
|
||||
if body.Capability.ProjectCreate.Allowed || body.Capability.ProjectCreate.Reason != "not-granted" {
|
||||
t.Fatalf("member project.create=%+v", body.Capability.ProjectCreate)
|
||||
}
|
||||
member := map[string]extension.CapabilityActionExplain{}
|
||||
for _, action := range body.MemberBasicCollaborate.Actions {
|
||||
member[action.ActionID] = action
|
||||
}
|
||||
if read := member["document.read"]; !read.Allowed || read.Source != "role" || read.Scope != "member-projects" {
|
||||
t.Fatalf("document.read=%+v", read)
|
||||
}
|
||||
if trace := member["trace.edit"]; trace.Allowed || !trace.CapAllowed || trace.Reason != "role-default-deny" {
|
||||
t.Fatalf("trace.edit=%+v", trace)
|
||||
}
|
||||
if audit := member["project.audit.read"]; audit.Allowed || audit.Reason != "action-unconfigured" {
|
||||
t.Fatalf("project.audit.read=%+v", audit)
|
||||
}
|
||||
duties := map[string]extension.DocumentDutyQualify{}
|
||||
for _, duty := range body.DocumentQualify.Duties {
|
||||
duties[duty.ActionID] = duty
|
||||
}
|
||||
if author := duties["document.author"]; !author.Qualified || !author.DefaultAllowed || author.Source != "role" {
|
||||
t.Fatalf("document.author=%+v", author)
|
||||
}
|
||||
if review := duties["document.review"]; review.Qualified || review.Reason != "action-unconfigured" {
|
||||
t.Fatalf("document.review=%+v", review)
|
||||
}
|
||||
if approve := duties["document.approve"]; approve.Qualified || approve.Reason != "role-cap" {
|
||||
t.Fatalf("document.approve=%+v", approve)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthorizedExplainReturnsNoLeakage(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
alice, _ := explainRoleFixture(t, h, st, admin, "alice", "项目创建者",
|
||||
`[{"actionId":"project.create","allowed":true,"capAllowed":true}]`)
|
||||
if _, err := st.CreateProject("alice", "alice-secret-project"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bobKey, err := createLegacyAPIKeyFixture(t, st, "bob", "bob-explain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noLeak := func(label string, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
for _, secret := range []string{"alice", alice.ID, "alice-secret-project"} {
|
||||
if strings.Contains(rec.Body.String(), secret) || strings.Contains(strings.Join(rec.Header().Values("X-Owner"), ","), secret) {
|
||||
t.Fatalf("%s leaked %q: %d %s", label, secret, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
// Naming another actor is refused identically whether it exists or not.
|
||||
var refused string
|
||||
for _, path := range []string{"/api/capability/explain?owner=alice", "/api/capability/explain?owner=nobody",
|
||||
"/api/capability/explain?userId=" + alice.ID, "/api/capability/explain?userId=user_missing",
|
||||
"/api/capability/explain?actor=alice", "/api/capability/explain?user=bob"} {
|
||||
rec := bearerRequest(h, "GET", path, "", bobKey)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s: %d %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
noLeak(path, rec)
|
||||
if refused == "" {
|
||||
refused = rec.Body.String()
|
||||
} else if rec.Body.String() != refused {
|
||||
t.Fatalf("%s distinguishable refusal %q vs %q", path, rec.Body.String(), refused)
|
||||
}
|
||||
}
|
||||
// A set-password-only session cannot read capability explanations.
|
||||
if err := st.SetUserPassword("bob", mustHash(t, "bob-temp-pass")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restricted, err := st.CreateSessionScoped("bob", store.SessionScopeSetPasswordOnly, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := do(h, "GET", "/api/capability/explain", "", restricted); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("restricted session: %d %s", rec.Code, rec.Body.String())
|
||||
} else {
|
||||
noLeak("restricted", rec)
|
||||
}
|
||||
// Missing, invalid and disabled credentials stop before any explanation.
|
||||
if rec := do(h, "GET", "/api/capability/explain", "", ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous: %d", rec.Code)
|
||||
} else {
|
||||
noLeak("anonymous", rec)
|
||||
}
|
||||
if rec := bearerRequest(h, "GET", "/api/capability/explain", "", "ak_invalid"); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("invalid key: %d", rec.Code)
|
||||
} else {
|
||||
noLeak("invalid", rec)
|
||||
}
|
||||
bob, _ := st.GetAccountByOwner("bob")
|
||||
if _, err := st.SetAccountEnabled("admin", bob.ID, bob.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := bearerRequest(h, "GET", "/api/capability/explain", "", bobKey); rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("disabled: %d %s", rec.Code, rec.Body.String())
|
||||
} else {
|
||||
noLeak("disabled", rec)
|
||||
}
|
||||
// Defense in depth: the handler itself refuses an unavailable actor.
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/capability/explain", nil)
|
||||
srv.explainCapability(rec, req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "bob")))
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != "capability explain denied\n" {
|
||||
t.Fatalf("handler disabled actor: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
srv.explainCapability(rec, req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "ghost")))
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != "capability explain denied\n" {
|
||||
t.Fatalf("handler missing actor: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func mustHash(t *testing.T, password string) string {
|
||||
t.Helper()
|
||||
hash, err := store.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return hash
|
||||
}
|
||||
|
||||
func TestCreateErrorCategorization(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := createLegacyAPIKeyFixture(t, st, "alice", "test")
|
||||
grantProjectCreateFixture(t, st, "alice")
|
||||
mallory, _ := createLegacyAPIKeyFixture(t, st, "mallory", "test")
|
||||
if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects", `{"name":"x"}`); w.Code != http.StatusForbidden {
|
||||
t.Fatalf("unauthorized create=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
code int
|
||||
body string
|
||||
}{
|
||||
{"duplicate", extension.WorkFailure{Operation: "rdm", Reason: extension.WorkFailureDuplicateName}, 409, "project.create: duplicate-name\n"},
|
||||
{"coded-duplicate", fmtWrap(leakyStatusError(409)), 409, "project.create: duplicate-name\n"},
|
||||
{"policy", extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}, 403, "project.create: policy-rejected\n"},
|
||||
{"identity", store.ErrAccountForbidden, 403, "project.create: policy-rejected\n"},
|
||||
{"mapping", fmtWrap(extension.WorkFailure{Reason: extension.WorkFailureMappingNotReady}), 503, "project.create: mapping-not-ready\n"},
|
||||
{"unavailable", leakyStatusError(503), 503, "project.create: service-unavailable\n"},
|
||||
{"deadline", context.DeadlineExceeded, 503, "project.create: service-unavailable\n"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := tc.err
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) {
|
||||
return core.Project{}, err
|
||||
}}, nil)
|
||||
w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`)
|
||||
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
|
||||
t.Fatalf("%s: %d %q", tc.name, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
s.projectLifecycle = nil
|
||||
if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"ok"}`); w.Code != http.StatusOK {
|
||||
t.Fatalf("store create=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberAddErrorCategorization(t *testing.T) {
|
||||
op := extension.WorkOperationMemberAdd
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
code int
|
||||
body string
|
||||
}{
|
||||
{"duplicate", extension.WorkFailure{Reason: extension.WorkFailureDuplicateName}, 409, "project.member.add: duplicate-name\n"},
|
||||
{"store-conflict", store.ErrConflict, 409, "project.member.add: duplicate-name\n"},
|
||||
{"coded-conflict", leakyStatusError(409), 409, "project.member.add: duplicate-name\n"},
|
||||
{"identity", store.ErrAccountForbidden, 403, "project.member.add: policy-rejected\n"},
|
||||
{"policy", fmtWrap(leakyStatusError(403)), 403, "project.member.add: policy-rejected\n"},
|
||||
{"mapping", extension.WorkFailure{Operation: op, Reason: extension.WorkFailureMappingNotReady}, 503, "project.member.add: mapping-not-ready\n"},
|
||||
{"unavailable", extension.WorkFailure{Reason: extension.WorkFailureServiceUnavailable}, 503, "project.member.add: service-unavailable\n"},
|
||||
{"conn-done", sql.ErrConnDone, 503, "project.member.add: service-unavailable\n"},
|
||||
// Unknown targets stay a plain not-found, indistinguishable from absence.
|
||||
{"not-found", store.ErrNotFound, 404, "store: not found\n"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
writeWorkFailure(w, op, tc.err)
|
||||
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
|
||||
t.Fatalf("%s: %d %q", tc.name, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if _, ok := categorizeWorkFailure(op, extension.WorkFailure{Reason: "invented"}); ok {
|
||||
t.Fatal("unknown reason categorized")
|
||||
}
|
||||
if failure, ok := extension.CategorizeWorkFailure(op, errors.Join(errors.New("rdm"), extension.WorkFailure{Operation: "other", Reason: extension.WorkFailurePolicyRejected})); !ok ||
|
||||
failure.Operation != op || failure.StatusCode() != http.StatusForbidden {
|
||||
t.Fatalf("provider categorization=%+v ok=%v", failure, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationWorkErrorCategorization(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
bob, _ := createLegacyAPIKeyFixture(t, st, "bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
var workErr error
|
||||
member := true
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) {
|
||||
return member, workErr
|
||||
}}, nil)
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
code int
|
||||
body string
|
||||
}{
|
||||
{"mapping", extension.WorkFailure{Reason: extension.WorkFailureMappingNotReady}, 503, "conversation.work: mapping-not-ready\n"},
|
||||
{"unavailable", fmtWrap(leakyStatusError(503)), 503, "conversation.work: service-unavailable\n"},
|
||||
{"policy", extension.WorkFailure{Reason: extension.WorkFailurePolicyRejected}, 403, "conversation.work: policy-rejected\n"},
|
||||
{"identity", store.ErrAccountForbidden, 403, "conversation.work: policy-rejected\n"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
workErr = tc.err
|
||||
for _, q := range []struct{ method, path, body string }{
|
||||
{"POST", "/api/projects/" + p.ID + "/conversations", `{"title":"t"}`},
|
||||
{"GET", "/api/projects/" + p.ID + "/conversations", ""},
|
||||
} {
|
||||
w := lifecycleRequest(t, s, bob, q.method, q.path, q.body)
|
||||
if w.Code != tc.code || w.Body.String() != tc.body || strings.Contains(w.Body.String(), leakMarker) {
|
||||
t.Fatalf("%s %s %s: %d %q", tc.name, q.method, q.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
// A non-member and an unknown project are both plain not-found.
|
||||
workErr, member = nil, false
|
||||
for _, path := range []string{"/api/projects/" + p.ID + "/conversations", "/api/projects/prj_missing/conversations"} {
|
||||
if w := lifecycleRequest(t, s, bob, "POST", path, `{}`); w.Code != http.StatusNotFound || strings.Contains(w.Body.String(), "alice") {
|
||||
t.Fatalf("nonmember %s: %d %q", path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
// Conversation work has no names, so a conflict is never a duplicate name.
|
||||
if _, ok := categorizeWorkFailure(extension.WorkOperationConversation, store.ErrConflict); ok {
|
||||
t.Fatal("conversation conflict categorized as duplicate name")
|
||||
}
|
||||
if _, ok := categorizeWorkFailure(extension.WorkOperationConversation, leakyStatusError(409)); ok {
|
||||
t.Fatal("conversation coded conflict categorized as duplicate name")
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,60 @@ func TestProjectCreateRequiresSystemOrRolePermission(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCreateLinksOwnProjectManagementOverHTTP(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
admin := bootAdmin(t, h, st)
|
||||
if err := st.CreateAccount("creator", "Creator", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
creator, _ := st.GetAccountByOwner("creator")
|
||||
key, err := st.CreateAPIKey("creator", "creator-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roleRec := do(h, "POST", "/api/admin/roles", `{"name":"项目创建者"}`, admin)
|
||||
if roleRec.Code != http.StatusOK {
|
||||
t.Fatalf("create role: %d %s", roleRec.Code, roleRec.Body.String())
|
||||
}
|
||||
role := decodeRoleHTTP(t, roleRec)
|
||||
conflict := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
|
||||
`{"expectedRoleVersion":1,"idempotencyKey":"create-conflict","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true},{"actionId":"project.members.manage","allowed":false,"capAllowed":false}]}`, admin)
|
||||
if conflict.Code != http.StatusBadRequest {
|
||||
t.Fatalf("linked conflict accepted: %d %s", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
stale := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
|
||||
`{"expectedRoleVersion":9,"idempotencyKey":"create-stale","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if stale.Code != http.StatusConflict {
|
||||
t.Fatalf("stale role version: %d %s", stale.Code, stale.Body.String())
|
||||
}
|
||||
policy := do(h, "PUT", "/api/admin/roles/"+role.RoleID+"/permissions",
|
||||
`{"expectedRoleVersion":1,"idempotencyKey":"create-only","changes":[{"actionId":"project.create","allowed":true,"capAllowed":true}]}`, admin)
|
||||
if policy.Code != http.StatusOK || !strings.Contains(policy.Body.String(), `"linkedExpansion":["project.settings","project.members.manage","project.permissions.manage","document.duties.assign"]`) {
|
||||
t.Fatalf("create-only policy: %d %s", policy.Code, policy.Body.String())
|
||||
}
|
||||
assign := do(h, "PUT", "/api/admin/users/"+creator.ID+"/role",
|
||||
`{"roleId":"`+role.RoleID+`","expectedStateVersion":`+strconv.FormatInt(creator.StateVersion, 10)+`,"idempotencyKey":"assign-creator"}`, admin)
|
||||
if assign.Code != http.StatusOK {
|
||||
t.Fatalf("assign creator: %d %s", assign.Code, assign.Body.String())
|
||||
}
|
||||
me := bearerRequest(h, "GET", "/api/me", "", key)
|
||||
var body struct {
|
||||
Linked struct {
|
||||
ProjectCreate bool `json:"projectCreate"`
|
||||
Source string `json:"source"`
|
||||
LinkedActions []string `json:"linkedActions"`
|
||||
} `json:"linkedCapabilities"`
|
||||
}
|
||||
if me.Code != http.StatusOK || json.Unmarshal(me.Body.Bytes(), &body) != nil {
|
||||
t.Fatalf("me: %d %s", me.Code, me.Body.String())
|
||||
}
|
||||
if !body.Linked.ProjectCreate || body.Linked.Source != "role" ||
|
||||
strings.Join(body.Linked.LinkedActions, ",") != "project.settings,project.members.manage,project.permissions.manage,document.duties.assign" {
|
||||
t.Fatalf("linked capabilities=%+v", body.Linked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUserActionsDoNotImplyRoleOrPermissionManagement(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
@@ -338,6 +338,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/admin/users/{userId}/system-grants", s.getSystemGrants)
|
||||
mux.HandleFunc("PUT /api/admin/users/{userId}/system-grants", s.replaceSystemGrants)
|
||||
mux.HandleFunc("GET /api/authz/explain", s.explainSystemAuthorization)
|
||||
mux.HandleFunc("GET /api/capability/explain", s.explainCapability)
|
||||
|
||||
// ---- 项目 / 会话 ----
|
||||
mux.HandleFunc("GET /api/projects", s.listProjects)
|
||||
@@ -766,11 +767,11 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name))
|
||||
writeProjectJSON(w, s.publicProjectMetadata(p), err)
|
||||
writeWorkJSON(w, extension.WorkOperationProjectCreate, s.publicProjectMetadata(p), err)
|
||||
return
|
||||
}
|
||||
p, err := s.st.CreateProjectAccountChecked(s.owner(r), strings.TrimSpace(req.Name), s.Auth == nil || !s.Auth.Enabled)
|
||||
writeJSONOr(w, p, err)
|
||||
writeWorkJSON(w, extension.WorkOperationProjectCreate, p, err)
|
||||
}
|
||||
|
||||
func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -871,7 +872,7 @@ func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
if s.projectLifecycle != nil {
|
||||
if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
writeWorkFailure(w, extension.WorkOperationConversation, err)
|
||||
return
|
||||
}
|
||||
} else if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
|
||||
@@ -912,6 +913,9 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
||||
out["mustChangePassword"] = st.MustChangePassword
|
||||
out["passwordSet"] = st.HasPassword
|
||||
out["systemActions"] = s.authorizer.EffectiveSystemActions(owner)
|
||||
if linked, err := s.authorizer.EffectiveLinkedCapabilities(owner); err == nil {
|
||||
out["linkedCapabilities"] = linked
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -926,7 +930,7 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||
req.Title = "新会话"
|
||||
}
|
||||
c, err := s.createPrivateConversation(r.Context(), s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
writeProjectJSON(w, c, err)
|
||||
writeWorkJSON(w, extension.WorkOperationConversation, c, err)
|
||||
}
|
||||
|
||||
// conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。
|
||||
@@ -1058,7 +1062,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
writeWorkFailure(w, extension.WorkOperationConversation, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
@@ -1352,7 +1356,7 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
hostProject = string(conv.ProjectID)
|
||||
if err := s.projectWork(r.Context(), s.owner(r), hostProject); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
writeWorkFailure(w, extension.WorkOperationConversation, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2415,6 +2419,14 @@ func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrRolePolicyVersion) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrRolePolicyRoleMissing) {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrInvalidRolePolicy) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/authz/catalog"
|
||||
)
|
||||
|
||||
const (
|
||||
CapabilitySourceRole = "role"
|
||||
CapabilitySourceSystemGrant = "system-grant"
|
||||
CapabilitySourceBootstrapAdmin = "bootstrap-admin"
|
||||
|
||||
CapabilityScopeSystem = "system"
|
||||
CapabilityScopeOwnProjects = "own-projects"
|
||||
CapabilityScopeMemberProjects = "member-projects"
|
||||
)
|
||||
|
||||
// memberBasicCollaborateActions are the role defaults a member brings into a
|
||||
// project; documentQualifyDuties are the three assignable document duties.
|
||||
var (
|
||||
memberBasicCollaborateActions = []string{"document.read", "trace.edit", "knowledge.publish", "project.audit.read"}
|
||||
documentQualifyDuties = []string{"document.author", "document.review", "document.approve"}
|
||||
)
|
||||
|
||||
type CapabilitySubject struct {
|
||||
UserID string `json:"userId"`
|
||||
Owner string `json:"owner"`
|
||||
AuthorityEpoch int64 `json:"authorityEpoch"`
|
||||
RoleID string `json:"roleId,omitempty"`
|
||||
RoleName string `json:"roleName,omitempty"`
|
||||
RoleEnabled bool `json:"roleEnabled"`
|
||||
}
|
||||
|
||||
type CapabilityActionExplain struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Scope string `json:"scope"`
|
||||
Allowed bool `json:"allowed"`
|
||||
CapAllowed bool `json:"capAllowed"`
|
||||
Source string `json:"source,omitempty"`
|
||||
LinkedVia string `json:"linkedVia,omitempty"`
|
||||
Conflict bool `json:"conflict,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type CapabilityExplain struct {
|
||||
ProjectCreate CapabilityActionExplain `json:"projectCreate"`
|
||||
Linked []CapabilityActionExplain `json:"linked"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
MigrationNote string `json:"migrationNote,omitempty"`
|
||||
}
|
||||
|
||||
type MemberBasicCollaborate struct {
|
||||
Actions []CapabilityActionExplain `json:"actions"`
|
||||
}
|
||||
|
||||
type DocumentDutyQualify struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Qualified bool `json:"qualified"`
|
||||
DefaultAllowed bool `json:"defaultAllowed"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type DocumentQualify struct {
|
||||
Duties []DocumentDutyQualify `json:"duties"`
|
||||
}
|
||||
|
||||
// CapabilityExplanation is one actor's creator, member and document-duty
|
||||
// explanation computed from a single authority snapshot.
|
||||
type CapabilityExplanation struct {
|
||||
CapabilitySubject
|
||||
Capability CapabilityExplain `json:"capability"`
|
||||
MemberBasicCollaborate MemberBasicCollaborate `json:"memberBasicCollaborate"`
|
||||
DocumentQualify DocumentQualify `json:"documentQualify"`
|
||||
}
|
||||
|
||||
func (s *Store) rolePermissionsTx(tx *sql.Tx, roleID string) (map[string]RolePermissionChange, error) {
|
||||
rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=?`), roleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]RolePermissionChange{}
|
||||
for rows.Next() {
|
||||
var permission RolePermissionChange
|
||||
if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[permission.ActionID] = permission
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// roleActionExplain uses the same reason vocabulary as the Host role check.
|
||||
func roleActionExplain(actionID, scope string, bound, enabled bool, permissions map[string]RolePermissionChange) CapabilityActionExplain {
|
||||
out := CapabilityActionExplain{ActionID: actionID, Scope: scope}
|
||||
if !bound {
|
||||
out.Reason = "role-unassigned"
|
||||
return out
|
||||
}
|
||||
if !enabled {
|
||||
out.Reason = "role-disabled"
|
||||
return out
|
||||
}
|
||||
permission, ok := permissions[actionID]
|
||||
switch {
|
||||
case !ok:
|
||||
out.Reason = "action-unconfigured"
|
||||
case !permission.CapAllowed:
|
||||
out.Reason = "role-cap"
|
||||
case !permission.Allowed:
|
||||
out.CapAllowed, out.Reason = true, "role-default-deny"
|
||||
default:
|
||||
out.Allowed, out.CapAllowed, out.Source = true, true, CapabilitySourceRole
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func capabilityExplainNote(projectCreate bool, conflicts, kept []string) string {
|
||||
if projectCreate && len(conflicts) > 0 {
|
||||
return "project.create linked actions capped by the enabled role: " + strings.Join(conflicts, ",") +
|
||||
"; the cap wins on created projects until the role row is changed explicitly"
|
||||
}
|
||||
if !projectCreate && len(kept) > 0 {
|
||||
return "project.create not held: linked capability rows " + strings.Join(kept, ",") +
|
||||
" remain on the role; remove them explicitly to revoke own-project management"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExplainCapabilities explains the owner's own capabilities. A missing or
|
||||
// disabled account is ErrAccountForbidden so callers cannot probe presence.
|
||||
func (s *Store) ExplainCapabilities(owner string) (CapabilityExplanation, error) {
|
||||
out := CapabilityExplanation{}
|
||||
if owner == "" {
|
||||
return out, ErrAccountForbidden
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := tx.QueryRow(`SELECT authority_epoch FROM authority_state WHERE id=1`).Scan(&out.AuthorityEpoch); err != nil {
|
||||
return out, err
|
||||
}
|
||||
var enabled, isAdmin bool
|
||||
err = tx.QueryRow(s.q(`SELECT id,owner,enabled,is_admin FROM users WHERE owner=?`), owner).Scan(&out.UserID, &out.Owner, &enabled, &isAdmin)
|
||||
if errors.Is(err, sql.ErrNoRows) || (err == nil && !enabled) {
|
||||
return CapabilityExplanation{}, ErrAccountForbidden
|
||||
}
|
||||
if err != nil {
|
||||
return CapabilityExplanation{}, err
|
||||
}
|
||||
state, err := s.loadCapabilityAssociationTx(tx, []string{out.UserID}, nil)
|
||||
if err != nil {
|
||||
return CapabilityExplanation{}, err
|
||||
}
|
||||
roleID, bound := state.bindings[out.UserID]
|
||||
permissions := map[string]RolePermissionChange{}
|
||||
if bound {
|
||||
role, err := s.roleTx(tx, roleID, false)
|
||||
if err != nil {
|
||||
return CapabilityExplanation{}, err
|
||||
}
|
||||
out.RoleID, out.RoleName, out.RoleEnabled = role.RoleID, role.Name, role.Enabled
|
||||
if permissions, err = s.rolePermissionsTx(tx, roleID); err != nil {
|
||||
return CapabilityExplanation{}, err
|
||||
}
|
||||
}
|
||||
var enabledPermissions map[string]RolePermissionChange
|
||||
if out.RoleEnabled {
|
||||
enabledPermissions = permissions
|
||||
}
|
||||
|
||||
create := roleActionExplain(catalog.ProjectCreateAction, CapabilityScopeSystem, bound, out.RoleEnabled, permissions)
|
||||
switch {
|
||||
case isAdmin:
|
||||
create.Allowed, create.Source, create.Reason = true, CapabilitySourceBootstrapAdmin, ""
|
||||
case state.creators[out.UserID]:
|
||||
create.Allowed, create.Source, create.Reason = true, CapabilitySourceSystemGrant, ""
|
||||
case create.Allowed:
|
||||
case create.Reason == "role-unassigned" || create.Reason == "action-unconfigured":
|
||||
create.Reason = "not-granted"
|
||||
}
|
||||
capability := CapabilityExplain{ProjectCreate: create, Linked: []CapabilityActionExplain{}, Conflicts: []string{}}
|
||||
kept := []string{}
|
||||
for _, actionID := range catalog.ProjectCreateLinkedActions() {
|
||||
if _, exists := permissions[actionID]; exists {
|
||||
kept = append(kept, actionID)
|
||||
}
|
||||
linked := CapabilityActionExplain{ActionID: actionID, Scope: CapabilityScopeOwnProjects}
|
||||
switch {
|
||||
case !create.Allowed:
|
||||
linked.Reason = "project-create-not-held"
|
||||
case linkedActionDenied(enabledPermissions, actionID):
|
||||
linked.LinkedVia, linked.Conflict, linked.Reason = catalog.ProjectCreateAction, true, "role-cap"
|
||||
capability.Conflicts = append(capability.Conflicts, actionID)
|
||||
default:
|
||||
linked.Allowed, linked.CapAllowed, linked.Source, linked.LinkedVia = true, true, create.Source, catalog.ProjectCreateAction
|
||||
}
|
||||
capability.Linked = append(capability.Linked, linked)
|
||||
}
|
||||
capability.MigrationNote = capabilityExplainNote(create.Allowed, capability.Conflicts, kept)
|
||||
out.Capability = capability
|
||||
|
||||
out.MemberBasicCollaborate.Actions = make([]CapabilityActionExplain, 0, len(memberBasicCollaborateActions))
|
||||
for _, actionID := range memberBasicCollaborateActions {
|
||||
out.MemberBasicCollaborate.Actions = append(out.MemberBasicCollaborate.Actions,
|
||||
roleActionExplain(actionID, CapabilityScopeMemberProjects, bound, out.RoleEnabled, permissions))
|
||||
}
|
||||
out.DocumentQualify.Duties = make([]DocumentDutyQualify, 0, len(documentQualifyDuties))
|
||||
for _, actionID := range documentQualifyDuties {
|
||||
decision := roleActionExplain(actionID, CapabilityScopeMemberProjects, bound, out.RoleEnabled, permissions)
|
||||
duty := DocumentDutyQualify{ActionID: actionID, Qualified: decision.CapAllowed, DefaultAllowed: decision.Allowed, Reason: decision.Reason}
|
||||
if duty.Qualified {
|
||||
duty.Source = CapabilitySourceRole
|
||||
}
|
||||
out.DocumentQualify.Duties = append(out.DocumentQualify.Duties, duty)
|
||||
}
|
||||
return out, tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func explainActionMap(actions []CapabilityActionExplain) map[string]CapabilityActionExplain {
|
||||
out := map[string]CapabilityActionExplain{}
|
||||
for _, action := range actions {
|
||||
out[action.ActionID] = action
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestExplainCapabilityCreatorSourcesMatchLinkedCapabilities(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
_, alice, bob := rolePolicyAccounts(t, st)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "项目负责人"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "explain-create"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "explain-assign"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epoch, _ := st.AuthorityEpoch()
|
||||
if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "explain-grant"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for owner, source := range map[string]string{"alice": "role", "bob": "system-grant", "admin": "bootstrap-admin"} {
|
||||
explanation, err := st.ExplainCapabilities(owner)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", owner, err)
|
||||
}
|
||||
linked, err := st.LinkedCapabilities(owner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capability := explanation.Capability
|
||||
if explanation.Owner != owner || !capability.ProjectCreate.Allowed || capability.ProjectCreate.Source != source ||
|
||||
capability.ProjectCreate.Source != linked.Source || capability.ProjectCreate.Scope != CapabilityScopeSystem ||
|
||||
len(capability.Conflicts) != 0 || capability.MigrationNote != "" || explanation.AuthorityEpoch != linked.AuthorityEpoch {
|
||||
t.Fatalf("%s explanation=%+v linked=%+v", owner, explanation, linked)
|
||||
}
|
||||
allowed := []string{}
|
||||
for _, action := range capability.Linked {
|
||||
if !action.Allowed || action.Source != source || action.LinkedVia != "project.create" || action.Scope != CapabilityScopeOwnProjects {
|
||||
t.Fatalf("%s linked action=%+v", owner, action)
|
||||
}
|
||||
allowed = append(allowed, action.ActionID)
|
||||
}
|
||||
if strings.Join(allowed, ",") != strings.Join(linked.LinkedActions, ",") {
|
||||
t.Fatalf("%s explain linked=%v association=%v", owner, allowed, linked.LinkedActions)
|
||||
}
|
||||
}
|
||||
alicePolicy, _ := st.ExplainCapabilities("alice")
|
||||
if alicePolicy.RoleID != role.RoleID || alicePolicy.RoleName != "项目负责人" || !alicePolicy.RoleEnabled {
|
||||
t.Fatalf("alice subject=%+v", alicePolicy.CapabilitySubject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainCapabilityReportsConflictsAndMigrationNote(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
admin, alice, _ := rolePolicyAccounts(t, st)
|
||||
// Bootstrap admin keeps project.create through is_admin even without the
|
||||
// grant, so a role cap on a linked action is reported as a conflict.
|
||||
epoch, _ := st.AuthorityEpoch()
|
||||
if _, err := st.ReplaceSystemGrants("admin", admin.ID, epoch, []string{"system.permissions.manage", "system.roles.manage", "system.roles.assign"}, "drop-admin-create"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capped, err := st.CreateRole("admin", RoleCreate{Name: "受限管理"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}, "cap-members"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin, _ = st.GetAccount(admin.ID)
|
||||
if _, err := st.AssignUserRole("admin", admin.ID, capped.RoleID, admin.StateVersion, "assign-admin-capped"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, err := st.ExplainCapabilities("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linked, _ := st.LinkedCapabilities("admin")
|
||||
capability := explanation.Capability
|
||||
if capability.ProjectCreate.Source != "bootstrap-admin" || strings.Join(capability.Conflicts, ",") != "project.members.manage" ||
|
||||
strings.Join(capability.Conflicts, ",") != strings.Join(linked.Conflicts, ",") {
|
||||
t.Fatalf("conflicts=%+v linked=%+v", capability, linked)
|
||||
}
|
||||
members := explainActionMap(capability.Linked)["project.members.manage"]
|
||||
if members.Allowed || !members.Conflict || members.Reason != "role-cap" || members.Source != "" {
|
||||
t.Fatalf("conflicting linked action=%+v", members)
|
||||
}
|
||||
if !strings.Contains(capability.MigrationNote, "project.members.manage") {
|
||||
t.Fatalf("conflict note=%q", capability.MigrationNote)
|
||||
}
|
||||
|
||||
// Disabling project.create keeps linked rows; the explanation says so.
|
||||
creator, err := st.CreateRole("admin", RoleCreate{Name: "创建者"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := st.ReplaceRolePermissions("admin", creator.RoleID, creator.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "project.settings", Allowed: true, CapAllowed: true},
|
||||
}, "enable-create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, creator.RoleID, alice.StateVersion, "assign-alice-creator"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", creator.RoleID, receipt.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "disable-create"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled, err := st.ExplainCapabilities("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if disabled.Capability.ProjectCreate.Allowed || disabled.Capability.ProjectCreate.Reason != "not-granted" {
|
||||
t.Fatalf("disabled create=%+v", disabled.Capability.ProjectCreate)
|
||||
}
|
||||
for _, action := range disabled.Capability.Linked {
|
||||
if action.Allowed || action.Reason != "project-create-not-held" {
|
||||
t.Fatalf("linked after disable=%+v", action)
|
||||
}
|
||||
}
|
||||
if note := disabled.Capability.MigrationNote; !strings.Contains(note, "project.create not held") || !strings.Contains(note, "project.settings") {
|
||||
t.Fatalf("migration note=%q", note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainCapabilityMemberBasicCollaborate(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
_, alice, _ := rolePolicyAccounts(t, st)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "协作成员"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "document.read", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "trace.edit", Allowed: false, CapAllowed: true},
|
||||
{ActionID: "knowledge.publish", Allowed: false, CapAllowed: false},
|
||||
}, "member-policy"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "member-assign"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, err := st.ExplainCapabilities("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actions := explainActionMap(explanation.MemberBasicCollaborate.Actions)
|
||||
want := map[string]CapabilityActionExplain{
|
||||
"document.read": {ActionID: "document.read", Scope: CapabilityScopeMemberProjects, Allowed: true, CapAllowed: true, Source: "role"},
|
||||
"trace.edit": {ActionID: "trace.edit", Scope: CapabilityScopeMemberProjects, CapAllowed: true, Reason: "role-default-deny"},
|
||||
"knowledge.publish": {ActionID: "knowledge.publish", Scope: CapabilityScopeMemberProjects, Reason: "role-cap"},
|
||||
"project.audit.read": {ActionID: "project.audit.read", Scope: CapabilityScopeMemberProjects, Reason: "action-unconfigured"},
|
||||
}
|
||||
if len(actions) != len(want) {
|
||||
t.Fatalf("actions=%+v", explanation.MemberBasicCollaborate.Actions)
|
||||
}
|
||||
for actionID, expected := range want {
|
||||
if actions[actionID] != expected {
|
||||
t.Fatalf("%s=%+v want %+v", actionID, actions[actionID], expected)
|
||||
}
|
||||
}
|
||||
unassigned, err := st.ExplainCapabilities("bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, action := range unassigned.MemberBasicCollaborate.Actions {
|
||||
if action.Allowed || action.Reason != "role-unassigned" {
|
||||
t.Fatalf("unassigned member action=%+v", action)
|
||||
}
|
||||
}
|
||||
// Bootstrap admin is a system source only; it grants no member content.
|
||||
adminExplain, err := st.ExplainCapabilities("admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, action := range adminExplain.MemberBasicCollaborate.Actions {
|
||||
if action.Allowed || action.Source != "" {
|
||||
t.Fatalf("admin member action=%+v", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainCapabilityDocumentQualify(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
_, alice, _ := rolePolicyAccounts(t, st)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "文档职责"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "document.author", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "document.review", Allowed: false, CapAllowed: true},
|
||||
{ActionID: "document.approve", Allowed: false, CapAllowed: false},
|
||||
}, "duty-policy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "duty-assign"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, err := st.ExplainCapabilities("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]DocumentDutyQualify{}
|
||||
for _, duty := range explanation.DocumentQualify.Duties {
|
||||
got[duty.ActionID] = duty
|
||||
}
|
||||
want := map[string]DocumentDutyQualify{
|
||||
"document.author": {ActionID: "document.author", Qualified: true, DefaultAllowed: true, Source: "role"},
|
||||
"document.review": {ActionID: "document.review", Qualified: true, Source: "role", Reason: "role-default-deny"},
|
||||
"document.approve": {ActionID: "document.approve", Reason: "role-cap"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("duties=%+v", explanation.DocumentQualify.Duties)
|
||||
}
|
||||
for actionID, expected := range want {
|
||||
if got[actionID] != expected {
|
||||
t.Fatalf("%s=%+v want %+v", actionID, got[actionID], expected)
|
||||
}
|
||||
}
|
||||
disabled := false
|
||||
if _, err := st.PatchRole("admin", role.RoleID, receipt.RoleVersion, RolePatch{Enabled: &disabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, err = st.ExplainCapabilities("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, duty := range explanation.DocumentQualify.Duties {
|
||||
if duty.Qualified || duty.Reason != "role-disabled" {
|
||||
t.Fatalf("disabled role duty=%+v", duty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainCapabilityRejectsMissingAndDisabledAccountsAlike(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
rolePolicyAccounts(t, st)
|
||||
if err := st.CreateAccount("carol", "Carol", "hash", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
carol, _ := st.GetAccountByOwner("carol")
|
||||
if _, err := st.SetAccountEnabled("admin", carol.ID, carol.StateVersion, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, owner := range []string{"", "nobody", "carol"} {
|
||||
explanation, err := st.ExplainCapabilities(owner)
|
||||
if !errors.Is(err, ErrAccountForbidden) || explanation.UserID != "" || explanation.Owner != "" {
|
||||
t.Fatalf("%q explanation=%+v err=%v", owner, explanation, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,6 +17,10 @@ import (
|
||||
var (
|
||||
ErrInvalidRolePolicy = errors.New("store: invalid role policy")
|
||||
ErrLastPermissionManager = errors.New("store: final permission manager")
|
||||
// Role permission writers reject a stale or missing role as an invalid
|
||||
// policy; the wrapped conflict/not-found class keeps HTTP retry semantics.
|
||||
ErrRolePolicyVersion = fmt.Errorf("%w: role version changed: %w", ErrInvalidRolePolicy, ErrConflict)
|
||||
ErrRolePolicyRoleMissing = fmt.Errorf("%w: role missing: %w", ErrInvalidRolePolicy, ErrNotFound)
|
||||
)
|
||||
|
||||
type RoleCreate struct {
|
||||
@@ -52,6 +57,20 @@ type PolicyReceipt struct {
|
||||
ReceiptID string `json:"receiptId"`
|
||||
AuthorityEpoch int64 `json:"authorityEpoch"`
|
||||
RoleVersion int64 `json:"roleVersion,omitempty"`
|
||||
// LinkedExpansion lists linked rows the write added for project.create.
|
||||
LinkedExpansion []string `json:"linkedExpansion,omitempty"`
|
||||
MigrationNote string `json:"migrationNote,omitempty"`
|
||||
}
|
||||
|
||||
// CapabilityAssociation is one actor's project.create link computed from a
|
||||
// single authority snapshot. HTTP and command surfaces both read it here.
|
||||
// Conflicts lists linked actions an enabled role still caps off.
|
||||
type CapabilityAssociation struct {
|
||||
ProjectCreate bool `json:"projectCreate"`
|
||||
Source string `json:"source,omitempty"`
|
||||
LinkedActions []string `json:"linkedActions"`
|
||||
Conflicts []string `json:"conflicts,omitempty"`
|
||||
AuthorityEpoch int64 `json:"authorityEpoch"`
|
||||
}
|
||||
|
||||
// RoleRecord is the stable position definition stored by Backend.
|
||||
@@ -359,25 +378,64 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha
|
||||
return err
|
||||
}
|
||||
role, err := s.roleTx(tx, roleID, true)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return ErrRolePolicyRoleMissing
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role.RoleVersion != expected {
|
||||
return ErrConflict
|
||||
return ErrRolePolicyVersion
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
next := map[string]RolePermissionChange{}
|
||||
for _, change := range changes {
|
||||
descriptor, ok := catalog.DescriptorByID(change.ActionID)
|
||||
if !ok || descriptor.Protected || seen[change.ActionID] || (change.Allowed && !change.CapAllowed) {
|
||||
return ErrInvalidRolePolicy
|
||||
}
|
||||
seen[change.ActionID] = true
|
||||
next[change.ActionID] = change
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM role_permissions WHERE role_id=?`), roleID); err != nil {
|
||||
previous, err := s.loadCapabilityAssociationTx(tx, nil, []string{roleID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wasCreator, isCreator := projectCreateEnabled(previous.roles[roleID]), projectCreateEnabled(next)
|
||||
kept, expanded := []string{}, []string{}
|
||||
rows := append([]RolePermissionChange(nil), changes...)
|
||||
if wasCreator || isCreator {
|
||||
for _, actionID := range catalog.ProjectCreateLinkedActions() {
|
||||
if seen[actionID] {
|
||||
continue
|
||||
}
|
||||
if _, existed := previous.roles[roleID][actionID]; existed {
|
||||
kept = append(kept, actionID)
|
||||
} else if isCreator {
|
||||
// The cap links management to the creator's own projects only;
|
||||
// the default stays off for every other member project.
|
||||
rows = append(rows, RolePermissionChange{ActionID: actionID, Allowed: false, CapAllowed: true})
|
||||
expanded = append(expanded, actionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
migrationNote := ""
|
||||
if wasCreator && !isCreator {
|
||||
migrationNote = projectCreateDisabledNote(kept)
|
||||
}
|
||||
deleteQuery := `DELETE FROM role_permissions WHERE role_id=?`
|
||||
deleteArgs := []any{roleID}
|
||||
if len(kept) > 0 {
|
||||
deleteQuery += ` AND action_id NOT IN (` + strings.TrimSuffix(strings.Repeat("?,", len(kept)), ",") + `)`
|
||||
for _, actionID := range kept {
|
||||
deleteArgs = append(deleteArgs, actionID)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(s.q(deleteQuery), deleteArgs...); err != nil {
|
||||
return err
|
||||
}
|
||||
now := nowISO()
|
||||
for _, change := range changes {
|
||||
for _, change := range rows {
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO role_permissions(role_id,action_id,allowed,cap_allowed,permission_version,updated_by,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?)`), roleID, change.ActionID, change.Allowed, change.CapAllowed, 1, actorAccount.ID, now); err != nil {
|
||||
return err
|
||||
@@ -388,12 +446,16 @@ func (s *Store) ReplaceRolePermissions(actor, roleID string, expected int64, cha
|
||||
return err
|
||||
}
|
||||
if n, _ := result.RowsAffected(); n != 1 {
|
||||
return ErrConflict
|
||||
return ErrRolePolicyVersion
|
||||
}
|
||||
if err := s.checkCapabilityAssociationTx(tx, nil, []string{roleID}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
|
||||
return err
|
||||
}
|
||||
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, RoleVersion: expected + 1}
|
||||
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, RoleVersion: expected + 1,
|
||||
LinkedExpansion: expanded, MigrationNote: migrationNote}
|
||||
return s.saveReceipt(tx, actorAccount.ID, key, "role.permissions.replace", requestHash, out)
|
||||
})
|
||||
return out, err
|
||||
@@ -448,6 +510,9 @@ func (s *Store) AssignUserRole(actor, userID, roleID string, expectedState int64
|
||||
if n, _ := result.RowsAffected(); n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := s.checkCapabilityAssociationTx(tx, []string{userID}, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -772,6 +837,22 @@ func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, a
|
||||
}
|
||||
seen[actionID] = true
|
||||
}
|
||||
previous, err := s.loadCapabilityAssociationTx(tx, []string{userID}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
migrationNote := ""
|
||||
if previous.creators[userID] && !seen[catalog.ProjectCreateAction] {
|
||||
kept := []string{}
|
||||
if roleID, bound := previous.bindings[userID]; bound {
|
||||
for _, actionID := range catalog.ProjectCreateLinkedActions() {
|
||||
if _, exists := previous.roles[roleID][actionID]; exists {
|
||||
kept = append(kept, actionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
migrationNote = projectCreateDisabledNote(kept)
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM system_grants WHERE user_id=?`), userID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -790,10 +871,13 @@ func (s *Store) ReplaceSystemGrants(actor, userID string, expectedEpoch int64, a
|
||||
if managers == 0 {
|
||||
return ErrLastPermissionManager
|
||||
}
|
||||
if err := s.checkCapabilityAssociationTx(tx, []string{userID}, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
|
||||
return err
|
||||
}
|
||||
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1}
|
||||
out = PolicyReceipt{ReceiptID: newID("receipt"), AuthorityEpoch: epoch + 1, MigrationNote: migrationNote}
|
||||
return s.saveReceipt(tx, actorAccount.ID, key, "system.grants.replace", requestHash, out)
|
||||
})
|
||||
return out, err
|
||||
@@ -872,6 +956,22 @@ func (s *Store) ImportRoleAuthority(actor string, in RoleAuthorityImport, key st
|
||||
if roleCount != 0 || bindingCount != 0 {
|
||||
return RoleAuthorityImportResult{}, ErrConflict
|
||||
}
|
||||
pending := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{}, bindings: map[string]string{}}
|
||||
boundUsers := []string{}
|
||||
for _, role := range roles {
|
||||
permissions := map[string]RolePermissionChange{}
|
||||
for _, permission := range role.Permissions {
|
||||
permissions[permission.ActionID] = permission
|
||||
}
|
||||
pending.roles[role.RoleID] = permissions
|
||||
for _, owner := range role.Owners {
|
||||
pending.bindings[accounts[owner].ID] = role.RoleID
|
||||
boundUsers = append(boundUsers, accounts[owner].ID)
|
||||
}
|
||||
}
|
||||
if err := s.checkCapabilityAssociationTx(tx, boundUsers, nil, &pending); err != nil {
|
||||
return RoleAuthorityImportResult{}, err
|
||||
}
|
||||
var epoch int64
|
||||
query := `SELECT authority_epoch FROM authority_state WHERE id=1`
|
||||
if s.Dialect() == "postgres" {
|
||||
@@ -1029,6 +1129,13 @@ func (s *Store) ActivateRoleAuthorityImport(actor, sourceSHA string, expectedEpo
|
||||
importedUsers++
|
||||
}
|
||||
}
|
||||
importedRoleIDs := make([]string, 0, len(staged.Roles))
|
||||
for _, role := range staged.Roles {
|
||||
importedRoleIDs = append(importedRoleIDs, role.RoleID)
|
||||
}
|
||||
if err := s.checkCapabilityAssociationTx(tx, nil, importedRoleIDs, nil); err != nil {
|
||||
return RoleAuthorityImportResult{}, err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE authority_state SET authority_epoch=?,updated_at=? WHERE id=1 AND authority_epoch=?`), epoch+1, now, epoch); err != nil {
|
||||
return RoleAuthorityImportResult{}, err
|
||||
}
|
||||
@@ -1047,3 +1154,229 @@ func (s *Store) ActivateRoleAuthorityImport(actor, sourceSHA string, expectedEpo
|
||||
}
|
||||
return RoleAuthorityImportResult{AuthorityEpoch: epoch + 1, ImportedRoles: len(staged.Roles), ImportedUsers: importedUsers}, nil
|
||||
}
|
||||
|
||||
// capabilityAssociation is the slice of authority that decides whether
|
||||
// project.create and its linked own-project management actions agree.
|
||||
type capabilityAssociation struct {
|
||||
roles map[string]map[string]RolePermissionChange // roleID -> actionID -> bits
|
||||
bindings map[string]string // userID -> active roleID
|
||||
creators map[string]bool // userID -> project.create system grant
|
||||
}
|
||||
|
||||
func projectCreateEnabled(permissions map[string]RolePermissionChange) bool {
|
||||
permission, ok := permissions[catalog.ProjectCreateAction]
|
||||
return ok && permission.Allowed && permission.CapAllowed
|
||||
}
|
||||
|
||||
// linkedActionDenied reports an explicit hard deny: the row caps the action
|
||||
// off. An absent row is unconfigured and derived from project.create; a row
|
||||
// with allowed=false under an open cap still serves the creator's own projects.
|
||||
func linkedActionDenied(permissions map[string]RolePermissionChange, actionID string) bool {
|
||||
permission, ok := permissions[actionID]
|
||||
return ok && !permission.CapAllowed
|
||||
}
|
||||
|
||||
func deniedLinkedAction(permissions map[string]RolePermissionChange) string {
|
||||
for _, actionID := range catalog.ProjectCreateLinkedActions() {
|
||||
if linkedActionDenied(permissions, actionID) {
|
||||
return actionID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func projectCreateDisabledNote(kept []string) string {
|
||||
if len(kept) == 0 {
|
||||
return "project.create disabled: no linked capability rows existed; nothing was deleted"
|
||||
}
|
||||
return "project.create disabled: kept linked capability rows " + strings.Join(kept, ",") +
|
||||
"; remove them explicitly to revoke own-project management"
|
||||
}
|
||||
|
||||
func associationActionArgs() []any {
|
||||
args := []any{catalog.ProjectCreateAction}
|
||||
for _, actionID := range catalog.ProjectCreateLinkedActions() {
|
||||
args = append(args, actionID)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// loadCapabilityAssociationTx reads the users' project.create grants and active
|
||||
// roles plus, for each role, its bound project.create grant holders and the
|
||||
// role rows for project.create and the linked actions.
|
||||
func (s *Store) loadCapabilityAssociationTx(tx *sql.Tx, userIDs, roleIDs []string) (capabilityAssociation, error) {
|
||||
state := capabilityAssociation{roles: map[string]map[string]RolePermissionChange{},
|
||||
bindings: map[string]string{}, creators: map[string]bool{}}
|
||||
roleIDs = append([]string(nil), roleIDs...)
|
||||
for _, userID := range userIDs {
|
||||
if _, loaded := state.creators[userID]; loaded {
|
||||
continue
|
||||
}
|
||||
var allowed bool
|
||||
err := tx.QueryRow(s.q(`SELECT allowed FROM system_grants WHERE user_id=? AND action_id=?`), userID, catalog.ProjectCreateAction).Scan(&allowed)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return state, err
|
||||
}
|
||||
state.creators[userID] = allowed
|
||||
var roleID string
|
||||
err = tx.QueryRow(s.q(`SELECT role_id FROM user_role_bindings WHERE user_id=? AND active=?`), userID, true).Scan(&roleID)
|
||||
switch {
|
||||
case err == nil:
|
||||
state.bindings[userID] = roleID
|
||||
roleIDs = append(roleIDs, roleID)
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return state, err
|
||||
}
|
||||
}
|
||||
actionArgs := associationActionArgs()
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(actionArgs)), ",")
|
||||
for _, roleID := range roleIDs {
|
||||
if _, loaded := state.roles[roleID]; loaded {
|
||||
continue
|
||||
}
|
||||
permissions := map[string]RolePermissionChange{}
|
||||
rows, err := tx.Query(s.q(`SELECT action_id,allowed,cap_allowed FROM role_permissions WHERE role_id=? AND action_id IN (`+placeholders+`)`),
|
||||
append([]any{roleID}, actionArgs...)...)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var permission RolePermissionChange
|
||||
if err := rows.Scan(&permission.ActionID, &permission.Allowed, &permission.CapAllowed); err != nil {
|
||||
rows.Close()
|
||||
return state, err
|
||||
}
|
||||
permissions[permission.ActionID] = permission
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return state, err
|
||||
}
|
||||
state.roles[roleID] = permissions
|
||||
holders, err := tx.Query(s.q(`SELECT b.user_id FROM user_role_bindings b
|
||||
JOIN system_grants g ON g.user_id=b.user_id AND g.action_id=? AND g.allowed=?
|
||||
WHERE b.role_id=? AND b.active=?`), catalog.ProjectCreateAction, true, roleID, true)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
for holders.Next() {
|
||||
var userID string
|
||||
if err := holders.Scan(&userID); err != nil {
|
||||
holders.Close()
|
||||
return state, err
|
||||
}
|
||||
state.creators[userID] = true
|
||||
state.bindings[userID] = roleID
|
||||
}
|
||||
if err := holders.Close(); err != nil {
|
||||
return state, err
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// checkCapabilityAssociationTx is the single in-transaction gate shared by all
|
||||
// role authority writers. It rejects any role that enables project.create while
|
||||
// denying a linked action, and any project.create grant holder whose active
|
||||
// role denies one. pending overlays rows staged but not yet written.
|
||||
func (s *Store) checkCapabilityAssociationTx(tx *sql.Tx, userIDs, roleIDs []string, pending *capabilityAssociation) error {
|
||||
state, err := s.loadCapabilityAssociationTx(tx, userIDs, roleIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pending != nil {
|
||||
for roleID, permissions := range pending.roles {
|
||||
state.roles[roleID] = permissions
|
||||
}
|
||||
for userID, roleID := range pending.bindings {
|
||||
state.bindings[userID] = roleID
|
||||
}
|
||||
for userID, allowed := range pending.creators {
|
||||
state.creators[userID] = allowed
|
||||
}
|
||||
}
|
||||
roleIDs = roleIDs[:0:0]
|
||||
for roleID := range state.roles {
|
||||
roleIDs = append(roleIDs, roleID)
|
||||
}
|
||||
sort.Strings(roleIDs)
|
||||
for _, roleID := range roleIDs {
|
||||
permissions := state.roles[roleID]
|
||||
if denied := deniedLinkedAction(permissions); denied != "" && projectCreateEnabled(permissions) {
|
||||
return fmt.Errorf("%w: role %s enables project.create but denies %s", ErrInvalidRolePolicy, roleID, denied)
|
||||
}
|
||||
}
|
||||
userIDs = userIDs[:0:0]
|
||||
for userID, creator := range state.creators {
|
||||
if creator {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
sort.Strings(userIDs)
|
||||
for _, userID := range userIDs {
|
||||
roleID, bound := state.bindings[userID]
|
||||
if !bound {
|
||||
continue
|
||||
}
|
||||
if denied := deniedLinkedAction(state.roles[roleID]); denied != "" {
|
||||
return fmt.Errorf("%w: project.create grant holder %s has role %s denying %s", ErrInvalidRolePolicy, userID, roleID, denied)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LinkedCapabilities computes, from one authority snapshot, whether the actor
|
||||
// holds project.create and which own-project management actions it links.
|
||||
// An explicit deny on the actor's enabled role still wins over the link.
|
||||
func (s *Store) LinkedCapabilities(owner string) (CapabilityAssociation, error) {
|
||||
out := CapabilityAssociation{LinkedActions: []string{}}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := tx.QueryRow(`SELECT authority_epoch FROM authority_state WHERE id=1`).Scan(&out.AuthorityEpoch); err != nil {
|
||||
return out, err
|
||||
}
|
||||
var userID string
|
||||
var enabled, isAdmin bool
|
||||
err = tx.QueryRow(s.q(`SELECT id,enabled,is_admin FROM users WHERE owner=?`), owner).Scan(&userID, &enabled, &isAdmin)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return out, ErrNotFound
|
||||
}
|
||||
if err != nil || !enabled {
|
||||
return out, err
|
||||
}
|
||||
state, err := s.loadCapabilityAssociationTx(tx, []string{userID}, nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var rolePermissions map[string]RolePermissionChange
|
||||
if roleID, bound := state.bindings[userID]; bound {
|
||||
role, err := s.roleTx(tx, roleID, false)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if role.Enabled {
|
||||
rolePermissions = state.roles[roleID]
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case isAdmin:
|
||||
out.Source = "bootstrap-admin"
|
||||
case state.creators[userID]:
|
||||
out.Source = "system-grant"
|
||||
case projectCreateEnabled(rolePermissions):
|
||||
out.Source = "role"
|
||||
default:
|
||||
return out, tx.Commit()
|
||||
}
|
||||
out.ProjectCreate = true
|
||||
for _, actionID := range catalog.ProjectCreateLinkedActions() {
|
||||
if linkedActionDenied(rolePermissions, actionID) {
|
||||
out.Conflicts = append(out.Conflicts, actionID)
|
||||
continue
|
||||
}
|
||||
out.LinkedActions = append(out.LinkedActions, actionID)
|
||||
}
|
||||
return out, tx.Commit()
|
||||
}
|
||||
|
||||
@@ -208,6 +208,291 @@ func TestRolePolicyIdempotencyKeyCannotChangeRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func capabilityRolePermissions(t *testing.T, st *Store, roleID string) map[string]RolePermissionRecord {
|
||||
t.Helper()
|
||||
permissions, err := st.RolePermissions(roleID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]RolePermissionRecord{}
|
||||
for _, permission := range permissions {
|
||||
out[permission.ActionID] = permission
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var linkedManagementActions = []string{"project.settings", "project.members.manage", "project.permissions.manage", "document.duties.assign"}
|
||||
|
||||
func TestCapabilityAssociationProjectCreateOnlyLinksFullManagement(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
_, alice, _ := rolePolicyAccounts(t, st)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "项目负责人"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "create-only")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(receipt.LinkedExpansion, ",") != strings.Join(linkedManagementActions, ",") {
|
||||
t.Fatalf("expansion=%v", receipt.LinkedExpansion)
|
||||
}
|
||||
permissions := capabilityRolePermissions(t, st, role.RoleID)
|
||||
for _, actionID := range linkedManagementActions {
|
||||
permission, ok := permissions[actionID]
|
||||
// The cap opens the creator's own projects; the default stays off.
|
||||
if !ok || permission.Allowed || !permission.CapAllowed {
|
||||
t.Fatalf("%s=%+v ok=%v", actionID, permission, ok)
|
||||
}
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-creator"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linked, err := st.LinkedCapabilities("alice")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !linked.ProjectCreate || linked.Source != "role" || strings.Join(linked.LinkedActions, ",") != strings.Join(linkedManagementActions, ",") || len(linked.Conflicts) != 0 {
|
||||
t.Fatalf("linked=%+v", linked)
|
||||
}
|
||||
if replayed, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}, "create-only"); err != nil || replayed.ReceiptID != receipt.ReceiptID || len(replayed.LinkedExpansion) != 4 {
|
||||
t.Fatalf("replay=%+v err=%v", replayed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityAssociationRejectsExplicitConflictAtomically(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
rolePolicyAccounts(t, st)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "受限创建者"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epoch, _ := st.AuthorityEpoch()
|
||||
conflict := []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "project.settings", Allowed: false, CapAllowed: false},
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, conflict, "explicit-conflict"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("explicit role conflict: %v", err)
|
||||
}
|
||||
after, _ := st.GetRole(role.RoleID)
|
||||
afterEpoch, _ := st.AuthorityEpoch()
|
||||
if len(capabilityRolePermissions(t, st, role.RoleID)) != 0 || after.RoleVersion != role.RoleVersion || afterEpoch != epoch {
|
||||
t.Fatalf("partial write: role=%+v epoch=%d->%d", after, epoch, afterEpoch)
|
||||
}
|
||||
// A cap already closed on the role still blocks a later project.create.
|
||||
capped := []RolePermissionChange{{ActionID: "project.members.manage", Allowed: false, CapAllowed: false}}
|
||||
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, capped, "platform-cap")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enable := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, enable, "enable-over-cap"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("existing platform cap conflict: %v", err)
|
||||
}
|
||||
permissions := capabilityRolePermissions(t, st, role.RoleID)
|
||||
if _, ok := permissions["project.create"]; ok || len(permissions) != 1 || permissions["project.members.manage"].CapAllowed {
|
||||
t.Fatalf("permissions after rejected enable=%+v", permissions)
|
||||
}
|
||||
resolved := append(enable, RolePermissionChange{ActionID: "project.members.manage", Allowed: false, CapAllowed: true})
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion, resolved, "explicitly-resolved"); err != nil {
|
||||
t.Fatalf("explicit resolution: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityAssociationRejectsStaleVersionAndMissingRole(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
rolePolicyAccounts(t, st)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "版本校验"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enable := []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}}
|
||||
_, err = st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion+1, enable, "stale-version")
|
||||
if !errors.Is(err, ErrInvalidRolePolicy) || !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("stale version: %v", err)
|
||||
}
|
||||
_, err = st.ReplaceRolePermissions("admin", "role_missing", 1, enable, "missing-role")
|
||||
if !errors.Is(err, ErrInvalidRolePolicy) || !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("missing role: %v", err)
|
||||
}
|
||||
if len(capabilityRolePermissions(t, st, role.RoleID)) != 0 {
|
||||
t.Fatal("rejected write left role permissions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkedCapabilitiesSystemGrantWithoutRoleClosesLoop(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
_, alice, bob := rolePolicyAccounts(t, st)
|
||||
if linked, err := st.LinkedCapabilities("bob"); err != nil || linked.ProjectCreate || len(linked.LinkedActions) != 0 {
|
||||
t.Fatalf("ungranted linked=%+v err=%v", linked, err)
|
||||
}
|
||||
epoch, _ := st.AuthorityEpoch()
|
||||
if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "grant-bob-create"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linked, err := st.LinkedCapabilities("bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !linked.ProjectCreate || linked.Source != "system-grant" || strings.Join(linked.LinkedActions, ",") != strings.Join(linkedManagementActions, ",") {
|
||||
t.Fatalf("direct grant linked=%+v", linked)
|
||||
}
|
||||
if admin, err := st.LinkedCapabilities("admin"); err != nil || admin.Source != "bootstrap-admin" || len(admin.LinkedActions) != 4 {
|
||||
t.Fatalf("admin linked=%+v err=%v", admin, err)
|
||||
}
|
||||
|
||||
capped, err := st.CreateRole("admin", RoleCreate{Name: "禁止管理"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cappedReceipt, err := st.ReplaceRolePermissions("admin", capped.RoleID, capped.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "document.duties.assign", Allowed: false, CapAllowed: false}}, "capped-role")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, _ = st.GetAccount(bob.ID)
|
||||
if _, err := st.AssignUserRole("admin", bob.ID, capped.RoleID, bob.StateVersion, "assign-capped-to-creator"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("capped role bound to project.create grant holder: %v", err)
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, capped.RoleID, alice.StateVersion, "assign-capped"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epoch, _ = st.AuthorityEpoch()
|
||||
if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"project.create"}, "grant-capped-create"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("system grant over role cap: %v", err)
|
||||
}
|
||||
if grants, _, err := st.SystemGrantsForUser(alice.ID); err != nil || len(grants) != 0 {
|
||||
t.Fatalf("rejected grant persisted=%v err=%v", grants, err)
|
||||
}
|
||||
// A role writer cannot later close a linked cap under a grant holder either.
|
||||
open, err := st.CreateRole("admin", RoleCreate{Name: "普通职位"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, _ = st.GetAccount(bob.ID)
|
||||
if _, err := st.AssignUserRole("admin", bob.ID, open.RoleID, bob.StateVersion, "assign-open"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ReplaceRolePermissions("admin", open.RoleID, open.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "project.settings", Allowed: false, CapAllowed: false}}, "cap-under-holder"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("role cap under grant holder: %v", err)
|
||||
}
|
||||
if permissions := capabilityRolePermissions(t, st, open.RoleID); len(permissions) != 0 {
|
||||
t.Fatalf("rejected role cap persisted: %+v", permissions)
|
||||
}
|
||||
if role, _ := st.GetRole(capped.RoleID); role.RoleVersion != cappedReceipt.RoleVersion {
|
||||
t.Fatalf("capped role changed: %+v", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityAssociationDisablingProjectCreateKeepsLinkedRows(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
_, alice, bob := rolePolicyAccounts(t, st)
|
||||
role, err := st.CreateRole("admin", RoleCreate{Name: "创建者"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := st.ReplaceRolePermissions("admin", role.RoleID, role.RoleVersion, []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "project.settings", Allowed: true, CapAllowed: true},
|
||||
}, "enable-create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AssignUserRole("admin", alice.ID, role.RoleID, alice.StateVersion, "assign-alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled, err := st.ReplaceRolePermissions("admin", role.RoleID, receipt.RoleVersion,
|
||||
[]RolePermissionChange{{ActionID: "document.read", Allowed: true, CapAllowed: true}}, "disable-create")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissions := capabilityRolePermissions(t, st, role.RoleID)
|
||||
if _, ok := permissions["project.create"]; ok {
|
||||
t.Fatalf("project.create still present: %+v", permissions)
|
||||
}
|
||||
if settings := permissions["project.settings"]; !settings.Allowed || !settings.CapAllowed {
|
||||
t.Fatalf("project.settings not kept: %+v", permissions)
|
||||
}
|
||||
for _, actionID := range linkedManagementActions {
|
||||
if _, ok := permissions[actionID]; !ok {
|
||||
t.Fatalf("%s deleted by disabling project.create: %+v", actionID, permissions)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(disabled.MigrationNote, "project.create disabled") || !strings.Contains(disabled.MigrationNote, "project.settings") {
|
||||
t.Fatalf("migration note=%q", disabled.MigrationNote)
|
||||
}
|
||||
if linked, err := st.LinkedCapabilities("alice"); err != nil || linked.ProjectCreate {
|
||||
t.Fatalf("disabled create still linked=%+v err=%v", linked, err)
|
||||
}
|
||||
// Removal after project.create is closed is an explicit second write.
|
||||
if _, err := st.ReplaceRolePermissions("admin", role.RoleID, disabled.RoleVersion, nil, "explicit-revoke"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if permissions := capabilityRolePermissions(t, st, role.RoleID); len(permissions) != 0 {
|
||||
t.Fatalf("explicit revoke kept=%+v", permissions)
|
||||
}
|
||||
|
||||
epoch, _ := st.AuthorityEpoch()
|
||||
if _, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, []string{"project.create"}, "grant-bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
epoch, _ = st.AuthorityEpoch()
|
||||
revoked, err := st.ReplaceSystemGrants("admin", bob.ID, epoch, nil, "revoke-bob")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(revoked.MigrationNote, "project.create disabled") {
|
||||
t.Fatalf("system grant migration note=%q", revoked.MigrationNote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityAssociationImportRejectsLinkedConflict(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
_, alice, _ := rolePolicyAccounts(t, st)
|
||||
bad := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("b", 64), Roles: []RoleAuthorityImportRole{{
|
||||
RoleID: "role_legacy_creator", Name: "旧创建者", Enabled: true, RoleVersion: 2,
|
||||
Permissions: []RolePermissionChange{
|
||||
{ActionID: "project.create", Allowed: true, CapAllowed: true},
|
||||
{ActionID: "project.permissions.manage", Allowed: false, CapAllowed: false},
|
||||
},
|
||||
}}}
|
||||
if _, err := st.ImportRoleAuthority("admin", bad, "import-conflict"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("conflicting import staged: %v", err)
|
||||
}
|
||||
epoch, _ := st.AuthorityEpoch()
|
||||
if _, err := st.ReplaceSystemGrants("admin", alice.ID, epoch, []string{"project.create"}, "grant-alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cappedForHolder := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("c", 64), Roles: []RoleAuthorityImportRole{{
|
||||
RoleID: "role_legacy_viewer", Name: "旧查看者", Enabled: true, RoleVersion: 1,
|
||||
Permissions: []RolePermissionChange{{ActionID: "project.settings", Allowed: false, CapAllowed: false}},
|
||||
Owners: []string{"alice"},
|
||||
}}}
|
||||
if _, err := st.ImportRoleAuthority("admin", cappedForHolder, "import-capped-holder"); !errors.Is(err, ErrInvalidRolePolicy) {
|
||||
t.Fatalf("import capped grant holder: %v", err)
|
||||
}
|
||||
good := RoleAuthorityImport{SourceSchemaVersion: 1, SourceSHA256: strings.Repeat("d", 64), Roles: []RoleAuthorityImportRole{{
|
||||
RoleID: "role_legacy_creator", Name: "旧创建者", Enabled: true, RoleVersion: 2,
|
||||
Permissions: []RolePermissionChange{{ActionID: "project.create", Allowed: true, CapAllowed: true}},
|
||||
Owners: []string{"bob"},
|
||||
}}}
|
||||
result, err := st.ImportRoleAuthority("admin", good, "import-good")
|
||||
if err != nil {
|
||||
t.Fatalf("valid import after rejected attempts: %v", err)
|
||||
}
|
||||
if _, err := st.ActivateRoleAuthorityImport("admin", good.SourceSHA256, result.AuthorityEpoch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linked, err := st.LinkedCapabilities("bob")
|
||||
if err != nil || linked.Source != "role" || len(linked.LinkedActions) != 4 {
|
||||
t.Fatalf("imported creator linked=%+v err=%v", linked, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRoleAuthorityPreservesStableIDsAndBindingsAtomically(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
for _, row := range []struct {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const CapabilityExplainSchemaVersion = 1
|
||||
|
||||
// Capability sources name where an allowed action comes from.
|
||||
const (
|
||||
CapabilitySourceRole = "role"
|
||||
CapabilitySourceSystemGrant = "system-grant"
|
||||
CapabilitySourceBootstrapAdmin = "bootstrap-admin"
|
||||
)
|
||||
|
||||
// Capability scopes name where an allowed action applies.
|
||||
const (
|
||||
CapabilityScopeSystem = "system"
|
||||
CapabilityScopeOwnProjects = "own-projects"
|
||||
CapabilityScopeMemberProjects = "member-projects"
|
||||
)
|
||||
|
||||
// CapabilitySubject identifies the Host-bound actor and the authority snapshot
|
||||
// an explanation was computed from. It never names another account.
|
||||
type CapabilitySubject struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
UserID string `json:"userId"`
|
||||
Owner string `json:"owner"`
|
||||
AuthorityEpoch int64 `json:"authorityEpoch"`
|
||||
RoleID string `json:"roleId,omitempty"`
|
||||
RoleName string `json:"roleName,omitempty"`
|
||||
RoleEnabled bool `json:"roleEnabled"`
|
||||
}
|
||||
|
||||
// CapabilityActionExplain reports one action's effective decision. Source is
|
||||
// set only when Allowed; LinkedVia names the action that links it; Conflict
|
||||
// marks a linked action that the enabled role caps off.
|
||||
type CapabilityActionExplain struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Scope string `json:"scope"`
|
||||
Allowed bool `json:"allowed"`
|
||||
CapAllowed bool `json:"capAllowed"`
|
||||
Source string `json:"source,omitempty"`
|
||||
LinkedVia string `json:"linkedVia,omitempty"`
|
||||
Conflict bool `json:"conflict,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// CapabilityExplain is the creator explanation: project.create, the linked
|
||||
// own-project management actions, their conflicts and the migration note.
|
||||
type CapabilityExplain struct {
|
||||
CapabilitySubject
|
||||
ProjectCreate CapabilityActionExplain `json:"projectCreate"`
|
||||
Linked []CapabilityActionExplain `json:"linked"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
MigrationNote string `json:"migrationNote,omitempty"`
|
||||
}
|
||||
|
||||
// MemberBasicCollaborate explains the role defaults a member brings to the
|
||||
// projects they are a member of. Bootstrap admin is not a content source.
|
||||
type MemberBasicCollaborate struct {
|
||||
CapabilitySubject
|
||||
Actions []CapabilityActionExplain `json:"actions"`
|
||||
}
|
||||
|
||||
// DocumentDutyQualify reports whether the role cap lets a document duty be
|
||||
// assigned to the actor and whether the role grants it by default.
|
||||
type DocumentDutyQualify struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Qualified bool `json:"qualified"`
|
||||
DefaultAllowed bool `json:"defaultAllowed"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type DocumentQualify struct {
|
||||
CapabilitySubject
|
||||
Duties []DocumentDutyQualify `json:"duties"`
|
||||
}
|
||||
|
||||
// CapabilityExplainProvider is optional on a trusted dispatch Host. Every
|
||||
// method explains only the Host-bound actor; callers cannot name another one.
|
||||
type CapabilityExplainProvider interface {
|
||||
CurrentCapabilityExplain(context.Context) (CapabilityExplain, error)
|
||||
CurrentMemberBasicCollaborate(context.Context) (MemberBasicCollaborate, error)
|
||||
CurrentDocumentQualify(context.Context) (DocumentQualify, error)
|
||||
}
|
||||
|
||||
// Work operations whose failures are categorized for clients.
|
||||
const (
|
||||
WorkOperationProjectCreate = "project.create"
|
||||
WorkOperationMemberAdd = "project.member.add"
|
||||
WorkOperationConversation = "conversation.work"
|
||||
)
|
||||
|
||||
// Work failure reasons. Each maps to exactly one HTTP status.
|
||||
const (
|
||||
WorkFailureDuplicateName = "duplicate-name"
|
||||
WorkFailurePolicyRejected = "policy-rejected"
|
||||
WorkFailureMappingNotReady = "mapping-not-ready"
|
||||
WorkFailureServiceUnavailable = "service-unavailable"
|
||||
)
|
||||
|
||||
// WorkFailure is a categorized refusal a provider may return from project
|
||||
// create, member add or conversation work. Its text carries only the operation
|
||||
// and reason, never an account, project or name.
|
||||
type WorkFailure struct {
|
||||
Operation string `json:"operation"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (f WorkFailure) Error() string { return f.Operation + ": " + f.Reason }
|
||||
|
||||
// StatusCode returns 0 for an unknown reason so it is never trusted as a status.
|
||||
func (f WorkFailure) StatusCode() int {
|
||||
switch f.Reason {
|
||||
case WorkFailureDuplicateName:
|
||||
return http.StatusConflict
|
||||
case WorkFailurePolicyRejected:
|
||||
return http.StatusForbidden
|
||||
case WorkFailureMappingNotReady, WorkFailureServiceUnavailable:
|
||||
return http.StatusServiceUnavailable
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// CategorizeWorkFailure classifies a provider error for operation. A known
|
||||
// WorkFailure keeps its reason; a coded 403/503 (and 409 outside conversation
|
||||
// work) maps to its category; a deadline is service-unavailable. Anything else
|
||||
// is left uncategorized so the caller keeps its existing not-found handling.
|
||||
func CategorizeWorkFailure(operation string, err error) (WorkFailure, bool) {
|
||||
if err == nil {
|
||||
return WorkFailure{}, false
|
||||
}
|
||||
var failure WorkFailure
|
||||
if errors.As(err, &failure) && failure.StatusCode() != 0 {
|
||||
return WorkFailure{Operation: operation, Reason: failure.Reason}, true
|
||||
}
|
||||
var coded interface{ StatusCode() int }
|
||||
if errors.As(err, &coded) {
|
||||
switch coded.StatusCode() {
|
||||
case http.StatusForbidden:
|
||||
return WorkFailure{Operation: operation, Reason: WorkFailurePolicyRejected}, true
|
||||
case http.StatusConflict:
|
||||
if operation != WorkOperationConversation {
|
||||
return WorkFailure{Operation: operation, Reason: WorkFailureDuplicateName}, true
|
||||
}
|
||||
case http.StatusServiceUnavailable:
|
||||
return WorkFailure{Operation: operation, Reason: WorkFailureServiceUnavailable}, true
|
||||
}
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return WorkFailure{Operation: operation, Reason: WorkFailureServiceUnavailable}, true
|
||||
}
|
||||
return WorkFailure{}, false
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package extension
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type codedTestError int
|
||||
|
||||
func (e codedTestError) Error() string { return "provider detail about other-owner" }
|
||||
func (e codedTestError) StatusCode() int { return int(e) }
|
||||
|
||||
func TestWorkFailureStatusAndTextCarryNoIdentifiers(t *testing.T) {
|
||||
for reason, code := range map[string]int{
|
||||
WorkFailureDuplicateName: http.StatusConflict,
|
||||
WorkFailurePolicyRejected: http.StatusForbidden,
|
||||
WorkFailureMappingNotReady: http.StatusServiceUnavailable,
|
||||
WorkFailureServiceUnavailable: http.StatusServiceUnavailable,
|
||||
"invented": 0,
|
||||
} {
|
||||
failure := WorkFailure{Operation: WorkOperationMemberAdd, Reason: reason}
|
||||
if failure.StatusCode() != code || failure.Error() != WorkOperationMemberAdd+": "+reason {
|
||||
t.Fatalf("%s: status=%d text=%q", reason, failure.StatusCode(), failure.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategorizeWorkFailure(t *testing.T) {
|
||||
cases := []struct {
|
||||
op string
|
||||
err error
|
||||
reason string
|
||||
ok bool
|
||||
}{
|
||||
{WorkOperationProjectCreate, fmt.Errorf("wrap: %w", WorkFailure{Reason: WorkFailureMappingNotReady}), WorkFailureMappingNotReady, true},
|
||||
{WorkOperationProjectCreate, codedTestError(409), WorkFailureDuplicateName, true},
|
||||
{WorkOperationMemberAdd, codedTestError(403), WorkFailurePolicyRejected, true},
|
||||
{WorkOperationConversation, codedTestError(503), WorkFailureServiceUnavailable, true},
|
||||
{WorkOperationConversation, codedTestError(409), "", false},
|
||||
{WorkOperationConversation, context.DeadlineExceeded, WorkFailureServiceUnavailable, true},
|
||||
{WorkOperationMemberAdd, codedTestError(404), "", false},
|
||||
{WorkOperationMemberAdd, WorkFailure{Reason: "invented"}, "", false},
|
||||
{WorkOperationMemberAdd, errors.New("plain"), "", false},
|
||||
{WorkOperationMemberAdd, nil, "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
failure, ok := CategorizeWorkFailure(tc.op, tc.err)
|
||||
if ok != tc.ok || failure.Reason != tc.reason || (ok && failure.Operation != tc.op) || strings.Contains(failure.Error(), "other-owner") {
|
||||
t.Fatalf("%s %v: failure=%+v ok=%v", tc.op, tc.err, failure, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,13 @@ import (
|
||||
// SystemActionProvider for the Backend-owned single-effective-role policy;
|
||||
// resource queries carry the current authority epoch so downstream caches can
|
||||
// reject stale role snapshots.
|
||||
const SurfaceVersion = "2.25.0-experimental"
|
||||
// 2.26.0-experimental: IAM link U1 RolePolicySnapshot.Linked carries the
|
||||
// Backend-computed project.create own-project management link and its source.
|
||||
// 2.27.0-experimental: IAM link U3 optional CapabilityExplainProvider
|
||||
// (CapabilityExplain / MemberBasicCollaborate / DocumentQualify for the
|
||||
// Host-bound actor) and categorized WorkFailure for create, member add and
|
||||
// conversation work.
|
||||
const SurfaceVersion = "2.27.0-experimental"
|
||||
|
||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||
|
||||
@@ -49,6 +49,7 @@ type Handler func(ctx context.Context, h Host, req Request) (any, error)
|
||||
// - WorkTaskHost — X1:分发期建立可恢复的受信项目任务绑定
|
||||
// - HumanDecisionVerifier — 本次分发是否为认证人对确切待确认卡的响应
|
||||
// - RolePolicyProvider — Backend 权威职位与动作默认/上限
|
||||
// - CapabilityExplainProvider — 当前分发主体的创建者/成员/文档职责能力解释
|
||||
type Host interface {
|
||||
// PluginID 返回注册处理器的插件标识(形如 rdm)。
|
||||
PluginID() string
|
||||
|
||||
@@ -24,6 +24,18 @@ type RolePolicySnapshot struct {
|
||||
RoleVersion int64 `json:"roleVersion,omitempty"`
|
||||
AuthorityEpoch int64 `json:"authorityEpoch"`
|
||||
Permissions map[string]RolePermission `json:"permissions"`
|
||||
Linked LinkedCapabilities `json:"linked"`
|
||||
}
|
||||
|
||||
// LinkedCapabilities is the Backend-computed project.create link for one
|
||||
// account. Actions apply only to projects the account created; Source names
|
||||
// where project.create comes from (bootstrap-admin, system-grant or role) and
|
||||
// Conflicts lists linked actions that the account's enabled role caps off.
|
||||
type LinkedCapabilities struct {
|
||||
ProjectCreate bool `json:"projectCreate"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Actions []string `json:"actions"`
|
||||
Conflicts []string `json:"conflicts,omitempty"`
|
||||
}
|
||||
|
||||
type RoleActionDecision struct {
|
||||
|
||||
@@ -26,9 +26,26 @@ AdminProvider
|
||||
AdminProvider.IsAdmin
|
||||
AdminZoneSpec
|
||||
CLIHook
|
||||
CapabilityActionExplain
|
||||
CapabilityExplain
|
||||
CapabilityExplainProvider
|
||||
CapabilityExplainProvider.CurrentCapabilityExplain
|
||||
CapabilityExplainProvider.CurrentDocumentQualify
|
||||
CapabilityExplainProvider.CurrentMemberBasicCollaborate
|
||||
CapabilityExplainSchemaVersion
|
||||
CapabilityScopeMemberProjects
|
||||
CapabilityScopeOwnProjects
|
||||
CapabilityScopeSystem
|
||||
CapabilitySourceBootstrapAdmin
|
||||
CapabilitySourceRole
|
||||
CapabilitySourceSystemGrant
|
||||
CapabilitySubject
|
||||
CategorizeWorkFailure
|
||||
Compiled
|
||||
DelegatedProjectProjectionHost
|
||||
DelegatedProjectProjectionHost.ProjectProjectionForCreator
|
||||
DocumentDutyQualify
|
||||
DocumentQualify
|
||||
ErrNotLinked
|
||||
ErrProjectAuditDenied
|
||||
ErrProjectAuditLinkConflict
|
||||
@@ -64,7 +81,9 @@ HumanDecisionVerifier.RevokeHumanDecision
|
||||
HumanDecisionVerifier.VerifyHumanDecision
|
||||
InProc
|
||||
IntentSpec
|
||||
LinkedCapabilities
|
||||
Main
|
||||
MemberBasicCollaborate
|
||||
MergeHostOptions
|
||||
NewHostClient
|
||||
NewScopedHost
|
||||
@@ -239,6 +258,11 @@ WorkCompletionHost
|
||||
WorkCompletionHost.CompleteForWork
|
||||
WorkCompletionRequest
|
||||
WorkCompletionResult
|
||||
WorkFailure
|
||||
WorkFailureDuplicateName
|
||||
WorkFailureMappingNotReady
|
||||
WorkFailurePolicyRejected
|
||||
WorkFailureServiceUnavailable
|
||||
WorkObservation
|
||||
WorkObservationAccepted
|
||||
WorkObservationDenied
|
||||
@@ -246,5 +270,8 @@ WorkObservationPartial
|
||||
WorkObservationPending
|
||||
WorkObservationResult
|
||||
WorkObservationState
|
||||
WorkOperationConversation
|
||||
WorkOperationMemberAdd
|
||||
WorkOperationProjectCreate
|
||||
WorkTaskHost
|
||||
WorkTaskHost.TaskCreateForWork
|
||||
|
||||
Reference in New Issue
Block a user