diff --git a/daemon/api_prompting.go b/daemon/api_prompting.go index 5c9132ad572..b6a5a0fa7d1 100644 --- a/daemon/api_prompting.go +++ b/daemon/api_prompting.go @@ -290,19 +290,16 @@ var getInterfaceManager = func(c *Command) interfaceManager { } type postPromptBody struct { - Outcome prompting.OutcomeType `json:"action"` - Lifespan prompting.LifespanType `json:"lifespan"` - Duration string `json:"duration,omitempty"` - Constraints *prompting.Constraints `json:"constraints"` + Outcome prompting.OutcomeType `json:"action"` + Lifespan prompting.LifespanType `json:"lifespan"` + Duration string `json:"duration,omitempty"` + Constraints *prompting.ReplyConstraints `json:"constraints"` } type addRuleContents struct { Snap string `json:"snap"` Interface string `json:"interface"` Constraints *prompting.Constraints `json:"constraints"` - Outcome prompting.OutcomeType `json:"outcome"` - Lifespan prompting.LifespanType `json:"lifespan"` - Duration string `json:"duration,omitempty"` } type removeRulesSelector struct { @@ -311,10 +308,7 @@ type removeRulesSelector struct { } type patchRuleContents struct { - Constraints *prompting.Constraints `json:"constraints,omitempty"` - Outcome prompting.OutcomeType `json:"outcome,omitempty"` - Lifespan prompting.LifespanType `json:"lifespan,omitempty"` - Duration string `json:"duration,omitempty"` + Constraints *prompting.RuleConstraintsPatch `json:"constraints,omitempty"` } type postRulesRequestBody struct { @@ -465,7 +459,7 @@ func postRules(c *Command, r *http.Request, user *auth.UserState) Response { if postBody.AddRule == nil { return BadRequest(`must include "rule" field in request body when action is "add"`) } - newRule, err := getInterfaceManager(c).InterfacesRequestsManager().AddRule(userID, postBody.AddRule.Snap, postBody.AddRule.Interface, postBody.AddRule.Constraints, postBody.AddRule.Outcome, postBody.AddRule.Lifespan, postBody.AddRule.Duration) + newRule, err := getInterfaceManager(c).InterfacesRequestsManager().AddRule(userID, postBody.AddRule.Snap, postBody.AddRule.Interface, postBody.AddRule.Constraints) if err != nil { return promptingError(err) } @@ -542,7 +536,7 @@ func postRule(c *Command, r *http.Request, user *auth.UserState) Response { if postBody.PatchRule == nil { return BadRequest(`must include "rule" field in request body when action is "patch"`) } - patchedRule, err := getInterfaceManager(c).InterfacesRequestsManager().PatchRule(userID, ruleID, postBody.PatchRule.Constraints, postBody.PatchRule.Outcome, postBody.PatchRule.Lifespan, postBody.PatchRule.Duration) + patchedRule, err := getInterfaceManager(c).InterfacesRequestsManager().PatchRule(userID, ruleID, postBody.PatchRule.Constraints) if err != nil { return promptingError(err) } diff --git a/daemon/api_prompting_test.go b/daemon/api_prompting_test.go index ecbb70d8905..4eca389af63 100644 --- a/daemon/api_prompting_test.go +++ b/daemon/api_prompting_test.go @@ -52,15 +52,17 @@ type fakeInterfacesRequestsManager struct { err error // Store most recent received values - userID uint32 - snap string - iface string - id prompting.IDType // used for prompt ID or rule ID - constraints *prompting.Constraints - outcome prompting.OutcomeType - lifespan prompting.LifespanType - duration string - clientActivity bool + userID uint32 + snap string + iface string + id prompting.IDType // used for prompt ID or rule ID + ruleConstraints *prompting.Constraints + constraintsPatch *prompting.RuleConstraintsPatch + replyConstraints *prompting.ReplyConstraints + outcome prompting.OutcomeType + lifespan prompting.LifespanType + duration string + clientActivity bool } func (m *fakeInterfacesRequestsManager) Prompts(userID uint32, clientActivity bool) ([]*requestprompts.Prompt, error) { @@ -76,10 +78,10 @@ func (m *fakeInterfacesRequestsManager) PromptWithID(userID uint32, promptID pro return m.prompt, m.err } -func (m *fakeInterfacesRequestsManager) HandleReply(userID uint32, promptID prompting.IDType, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string, clientActivity bool) ([]prompting.IDType, error) { +func (m *fakeInterfacesRequestsManager) HandleReply(userID uint32, promptID prompting.IDType, constraints *prompting.ReplyConstraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string, clientActivity bool) ([]prompting.IDType, error) { m.userID = userID m.id = promptID - m.constraints = constraints + m.replyConstraints = constraints m.outcome = outcome m.lifespan = lifespan m.duration = duration @@ -94,14 +96,11 @@ func (m *fakeInterfacesRequestsManager) Rules(userID uint32, snap string, iface return m.rules, m.err } -func (m *fakeInterfacesRequestsManager) AddRule(userID uint32, snap string, iface string, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string) (*requestrules.Rule, error) { +func (m *fakeInterfacesRequestsManager) AddRule(userID uint32, snap string, iface string, constraints *prompting.Constraints) (*requestrules.Rule, error) { m.userID = userID m.snap = snap m.iface = iface - m.constraints = constraints - m.outcome = outcome - m.lifespan = lifespan - m.duration = duration + m.ruleConstraints = constraints return m.rule, m.err } @@ -118,13 +117,10 @@ func (m *fakeInterfacesRequestsManager) RuleWithID(userID uint32, ruleID prompti return m.rule, m.err } -func (m *fakeInterfacesRequestsManager) PatchRule(userID uint32, ruleID prompting.IDType, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string) (*requestrules.Rule, error) { +func (m *fakeInterfacesRequestsManager) PatchRule(userID uint32, ruleID prompting.IDType, constraintsPatch *prompting.RuleConstraintsPatch) (*requestrules.Rule, error) { m.userID = userID m.id = ruleID - m.constraints = constraints - m.outcome = outcome - m.lifespan = lifespan - m.duration = duration + m.constraintsPatch = constraintsPatch return m.rule, m.err } @@ -706,7 +702,7 @@ func (s *promptingSuite) TestPostPromptHappy(c *C) { prompting.IDType(0xF00BA4), } - constraints := &prompting.Constraints{ + constraints := &prompting.ReplyConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/Pictures/**/*.{png,svg}"), Permissions: []string{"read", "execute"}, } @@ -724,7 +720,7 @@ func (s *promptingSuite) TestPostPromptHappy(c *C) { // Check parameters c.Check(s.manager.userID, Equals, uint32(1000)) c.Check(s.manager.id, Equals, prompting.IDType(0x0123456789abcdef)) - c.Check(s.manager.constraints, DeepEquals, contents.Constraints) + c.Check(s.manager.replyConstraints, DeepEquals, contents.Constraints) c.Check(s.manager.outcome, Equals, contents.Outcome) c.Check(s.manager.lifespan, Equals, contents.Lifespan) c.Check(s.manager.duration, Equals, contents.Duration) @@ -782,13 +778,15 @@ func (s *promptingSuite) TestGetRulesHappy(c *C) { User: 1234, Snap: "firefox", Interface: "home", - Constraints: &prompting.Constraints{ + Constraints: &prompting.RuleConstraints{ PathPattern: mustParsePathPattern(c, "/foo/bar"), - Permissions: []string{"write"}, + Permissions: prompting.RulePermissionMap{ + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, }, - Outcome: prompting.OutcomeDeny, - Lifespan: prompting.LifespanForever, - Expiration: time.Now(), }, } @@ -817,26 +815,34 @@ func (s *promptingSuite) TestPostRulesAddHappy(c *C) { User: 11235, Snap: "firefox", Interface: "home", - Constraints: &prompting.Constraints{ + Constraints: &prompting.RuleConstraints{ PathPattern: mustParsePathPattern(c, "/foo/bar/baz"), - Permissions: []string{"write"}, + Permissions: prompting.RulePermissionMap{ + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, }, - Outcome: prompting.OutcomeDeny, - Lifespan: prompting.LifespanForever, - Expiration: time.Now(), } constraints := &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/{foo,bar,baz}/**/*.{png,svg}"), - Permissions: []string{"read", "write"}, + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, } contents := &daemon.AddRuleContents{ Snap: "thunderbird", Interface: "home", Constraints: constraints, - Outcome: prompting.OutcomeAllow, - Lifespan: prompting.LifespanForever, - Duration: "", } postBody := &daemon.PostRulesRequestBody{ Action: "add", @@ -851,10 +857,7 @@ func (s *promptingSuite) TestPostRulesAddHappy(c *C) { c.Check(s.manager.userID, Equals, uint32(11235)) c.Check(s.manager.snap, Equals, contents.Snap) c.Check(s.manager.iface, Equals, contents.Interface) - c.Check(s.manager.constraints, DeepEquals, contents.Constraints) - c.Check(s.manager.outcome, Equals, contents.Outcome) - c.Check(s.manager.lifespan, Equals, contents.Lifespan) - c.Check(s.manager.duration, Equals, contents.Duration) + c.Check(s.manager.ruleConstraints, DeepEquals, contents.Constraints) // Check return value rule, ok := rsp.Result.(*requestrules.Rule) @@ -888,7 +891,6 @@ func (s *promptingSuite) TestPostRulesRemoveHappy(c *C) { s.manager = &fakeInterfacesRequestsManager{} // Set the rules to return - var timeZero time.Time s.manager.rules = []*requestrules.Rule{ { ID: prompting.IDType(1234), @@ -896,13 +898,15 @@ func (s *promptingSuite) TestPostRulesRemoveHappy(c *C) { User: 1001, Snap: "thunderird", Interface: "home", - Constraints: &prompting.Constraints{ + Constraints: &prompting.RuleConstraints{ PathPattern: mustParsePathPattern(c, "/foo/bar/baz/qux"), - Permissions: []string{"write"}, + Permissions: prompting.RulePermissionMap{ + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, }, - Outcome: prompting.OutcomeDeny, - Lifespan: prompting.LifespanForever, - Expiration: timeZero, }, { ID: prompting.IDType(5678), @@ -910,13 +914,19 @@ func (s *promptingSuite) TestPostRulesRemoveHappy(c *C) { User: 1001, Snap: "thunderbird", Interface: "home", - Constraints: &prompting.Constraints{ + Constraints: &prompting.RuleConstraints{ PathPattern: mustParsePathPattern(c, "/fizz/buzz"), - Permissions: []string{"read", "execute"}, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + }, + "execute": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + }, + }, }, - Outcome: prompting.OutcomeAllow, - Lifespan: prompting.LifespanTimespan, - Expiration: time.Now(), }, } @@ -955,13 +965,16 @@ func (s *promptingSuite) TestGetRuleHappy(c *C) { User: 1005, Snap: "thunderbird", Interface: "home", - Constraints: &prompting.Constraints{ + Constraints: &prompting.RuleConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/Videos/**/*.{mkv,mp4,mov}"), - Permissions: []string{"read"}, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: time.Now().Add(-24 * time.Hour), + }, + }, }, - Outcome: prompting.OutcomeAllow, - Lifespan: prompting.LifespanTimespan, - Expiration: time.Now().Add(-24 * time.Hour), } rsp := s.makeSyncReq(c, "GET", "/v2/interfaces/requests/rules/000000000000012B", 1005, nil) @@ -981,31 +994,42 @@ func (s *promptingSuite) TestPostRulePatchHappy(c *C) { s.daemon(c) - var timeZero time.Time s.manager.rule = &requestrules.Rule{ ID: prompting.IDType(0x01123581321), Timestamp: time.Now(), User: 999, Snap: "gimp", Interface: "home", - Constraints: &prompting.Constraints{ + Constraints: &prompting.RuleConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/Pictures/**/*.{png,jpg}"), - Permissions: []string{"read", "write"}, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, }, - Outcome: prompting.OutcomeAllow, - Lifespan: prompting.LifespanForever, - Expiration: timeZero, } - constraints := &prompting.Constraints{ + constraintsPatch := &prompting.RuleConstraintsPatch{ PathPattern: mustParsePathPattern(c, "/home/test/Pictures/**/*.{png,jpg}"), - Permissions: []string{"read", "write"}, + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, } contents := &daemon.PatchRuleContents{ - Constraints: constraints, - Outcome: prompting.OutcomeAllow, - Lifespan: prompting.LifespanForever, - Duration: "", + Constraints: constraintsPatch, } postBody := &daemon.PostRuleRequestBody{ Action: "patch", @@ -1018,10 +1042,7 @@ func (s *promptingSuite) TestPostRulePatchHappy(c *C) { // Check parameters c.Check(s.manager.userID, Equals, uint32(999)) - c.Check(s.manager.constraints, DeepEquals, contents.Constraints) - c.Check(s.manager.outcome, Equals, contents.Outcome) - c.Check(s.manager.lifespan, Equals, contents.Lifespan) - c.Check(s.manager.duration, Equals, contents.Duration) + c.Check(s.manager.constraintsPatch, DeepEquals, contents.Constraints) // Check return value rule, ok := rsp.Result.(*requestrules.Rule) @@ -1034,20 +1055,25 @@ func (s *promptingSuite) TestPostRuleRemoveHappy(c *C) { s.daemon(c) - var timeZero time.Time s.manager.rule = &requestrules.Rule{ ID: prompting.IDType(0x01123581321), Timestamp: time.Now(), User: 100, Snap: "gimp", Interface: "home", - Constraints: &prompting.Constraints{ + Constraints: &prompting.RuleConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/Pictures/**/*.{png,jpg}"), - Permissions: []string{"read", "write"}, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, }, - Outcome: prompting.OutcomeAllow, - Lifespan: prompting.LifespanForever, - Expiration: timeZero, } postBody := &daemon.PostRuleRequestBody{ Action: "remove", diff --git a/interfaces/prompting/constraints.go b/interfaces/prompting/constraints.go index b07c04a039d..c6b32d77b57 100644 --- a/interfaces/prompting/constraints.go +++ b/interfaces/prompting/constraints.go @@ -22,6 +22,7 @@ package prompting import ( "fmt" "sort" + "time" prompting_errors "github.com/snapcore/snapd/interfaces/prompting/errors" "github.com/snapcore/snapd/interfaces/prompting/patterns" @@ -30,62 +31,90 @@ import ( "github.com/snapcore/snapd/strutil" ) -// Constraints hold information about the applicability of a rule to particular -// paths or permissions. A request matches the constraints if the requested path -// is matched by the path pattern (according to bash's globstar matching) and -// the requested permissions are contained in the constraints' permissions. +// Constraints hold information about the applicability of a new rule to +// particular paths and permissions. When creating a new rule, snapd converts +// Constraints to RuleConstraints. type Constraints struct { - PathPattern *patterns.PathPattern `json:"path-pattern,omitempty"` - Permissions []string `json:"permissions,omitempty"` + PathPattern *patterns.PathPattern `json:"path-pattern"` + Permissions PermissionMap `json:"permissions"` } -// ValidateForInterface returns nil if the constraints are valid for the given -// interface, otherwise returns an error. -func (c *Constraints) ValidateForInterface(iface string) error { +// Match returns true if the constraints match the given path, otherwise false. +// +// If the constraints or path are invalid, returns an error. +// +// This method is only intended to be called on constraints which have just +// been created from a reply, to check that the reply covers the request. +func (c *Constraints) Match(path string) (bool, error) { if c.PathPattern == nil { - return prompting_errors.NewInvalidPathPatternError("", "no path pattern") + return false, prompting_errors.NewInvalidPathPatternError("", "no path pattern") } - return c.validatePermissions(iface) + match, err := c.PathPattern.Match(path) + if err != nil { + // Error should not occur, since it was parsed internally + return false, prompting_errors.NewInvalidPathPatternError(c.PathPattern.String(), err.Error()) + } + return match, nil } -// validatePermissions checks that the permissions for the given constraints -// are valid for the given interface. If not, returns an error, otherwise -// ensures that the permissions are in the order in which they occur in the -// list of available permissions for that interface. -func (c *Constraints) validatePermissions(iface string) error { - availablePerms, ok := interfacePermissionsAvailable[iface] - if !ok { - return prompting_errors.NewInvalidInterfaceError(iface, availableInterfaces()) - } - permsSet := make(map[string]bool, len(c.Permissions)) - var invalidPerms []string - for _, perm := range c.Permissions { - if !strutil.ListContains(availablePerms, perm) { - invalidPerms = append(invalidPerms, perm) - continue +// ContainPermissions returns true if the permission map in the constraints +// includes every one of the given permissions. +// +// This method is only intended to be called on constraints which have just +// been created from a reply, to check that the reply covers the request. +func (c *Constraints) ContainPermissions(permissions []string) bool { + for _, perm := range permissions { + if _, exists := c.Permissions[perm]; !exists { + return false } - permsSet[perm] = true } - if len(invalidPerms) > 0 { - return prompting_errors.NewInvalidPermissionsError(iface, invalidPerms, availablePerms) + return true +} + +// ToRuleConstraints validates the receiving Constraints and converts it to +// RuleConstraints. If the constraints are not valid with respect to the given +// interface, returns an error. +func (c *Constraints) ToRuleConstraints(iface string, currTime time.Time) (*RuleConstraints, error) { + if c.PathPattern == nil { + return nil, prompting_errors.NewInvalidPathPatternError("", "no path pattern") + } + rulePermissions, err := c.Permissions.toRulePermissionMap(iface, currTime) + if err != nil { + return nil, err } - if len(permsSet) == 0 { - return prompting_errors.NewPermissionsListEmptyError(iface, availablePerms) + ruleConstraints := &RuleConstraints{ + PathPattern: c.PathPattern, + Permissions: rulePermissions, } - newPermissions := make([]string, 0, len(permsSet)) - for _, perm := range availablePerms { - if exists := permsSet[perm]; exists { - newPermissions = append(newPermissions, perm) - } + return ruleConstraints, nil +} + +// RuleConstraints hold information about the applicability of an existing rule +// to particular paths and permissions. A request will be matched by the rule +// constraints if the requested path is matched by the path pattern (according +// to bash's globstar matching) and one or more requested permissions are denied +// in the permission map, or all of the requested permissions are allowed in the +// map. +type RuleConstraints struct { + PathPattern *patterns.PathPattern `json:"path-pattern"` + Permissions RulePermissionMap `json:"permissions"` +} + +// ValidateForInterface checks that the rule constraints are valid for the given +// interface. Any permissions which have expired relative to the given current +// time are pruned. If all permissions have expired, then returns true. If the +// rule is invalid, returns an error. +func (c *RuleConstraints) ValidateForInterface(iface string, currTime time.Time) (expired bool, err error) { + if c.PathPattern == nil { + return false, prompting_errors.NewInvalidPathPatternError("", "no path pattern") } - c.Permissions = newPermissions - return nil + return c.Permissions.validateForInterface(iface, currTime) } // Match returns true if the constraints match the given path, otherwise false. // // If the constraints or path are invalid, returns an error. -func (c *Constraints) Match(path string) (bool, error) { +func (c *RuleConstraints) Match(path string) (bool, error) { if c.PathPattern == nil { return false, prompting_errors.NewInvalidPathPatternError("", "no path pattern") } @@ -97,17 +126,331 @@ func (c *Constraints) Match(path string) (bool, error) { return match, nil } -// ContainPermissions returns true if the constraints include every one of the -// given permissions. -func (c *Constraints) ContainPermissions(permissions []string) bool { - for _, perm := range permissions { - if !strutil.ListContains(c.Permissions, perm) { +// ReplyConstraints hold information about the applicability of a reply to +// particular paths and permissions. Upon receiving the reply, snapd converts +// ReplyConstraints to Constraints. +type ReplyConstraints struct { + PathPattern *patterns.PathPattern `json:"path-pattern"` + Permissions []string `json:"permissions"` +} + +// ToConstraints validates the receiving ReplyConstraints with respect to the +// given interface, along with the given outcome, lifespan, and duration, and +// constructs an equivalent Constraints from the ReplyConstraints. +func (c *ReplyConstraints) ToConstraints(iface string, outcome OutcomeType, lifespan LifespanType, duration string) (*Constraints, error) { + if _, err := outcome.AsBool(); err != nil { + // Should not occur, as outcome is validated when unmarshalled + return nil, err + } + if _, err := lifespan.ParseDuration(duration, time.Now()); err != nil { + return nil, err + } + if c.PathPattern == nil { + return nil, prompting_errors.NewInvalidPathPatternError("", "no path pattern") + } + availablePerms, ok := interfacePermissionsAvailable[iface] + if !ok { + return nil, prompting_errors.NewInvalidInterfaceError(iface, availableInterfaces()) + } + if len(c.Permissions) == 0 { + return nil, prompting_errors.NewPermissionsListEmptyError(iface, availablePerms) + } + var invalidPerms []string + permissionMap := make(PermissionMap, len(c.Permissions)) + for _, perm := range c.Permissions { + if !strutil.ListContains(availablePerms, perm) { + invalidPerms = append(invalidPerms, perm) + continue + } + permissionMap[perm] = &PermissionEntry{ + Outcome: outcome, + Lifespan: lifespan, + Duration: duration, + } + } + if len(invalidPerms) > 0 { + return nil, prompting_errors.NewInvalidPermissionsError(iface, invalidPerms, availablePerms) + } + constraints := &Constraints{ + PathPattern: c.PathPattern, + Permissions: permissionMap, + } + return constraints, nil +} + +// RuleConstraintsPatch hold partial rule contents which will be used to modify +// an existing rule. When snapd modifies the rule using RuleConstraintsPatch, +// it converts the RuleConstraintsPatch to RuleConstraints, using the rule's +// existing constraints wherever a field is omitted from the +// RuleConstraintsPatch. +// +// Any permissions which are omitted from the new permission map are left +// unchanged from the existing rule. To remove an existing permission from the +// rule, the permission should map to null. +type RuleConstraintsPatch struct { + PathPattern *patterns.PathPattern `json:"path-pattern,omitempty"` + Permissions PermissionMap `json:"permissions,omitempty"` +} + +// PatchRuleConstraints validates the receiving RuleConstraintsPatch and uses +// the given existing rule constraints to construct a new RuleConstraints. +// +// If the path pattern or permissions fields are omitted, they are left +// unchanged from the existing rule. If the permissions field is present in +// the patch, then any permissions which are omitted from the patch's +// permission map are left unchanged from the existing rule. To remove an +// existing permission from the rule, the permission should map to null in the +// permission map of the patch. +// +// The existing rule constraints should never be modified. +func (c *RuleConstraintsPatch) PatchRuleConstraints(existing *RuleConstraints, iface string, currTime time.Time) (*RuleConstraints, error) { + ruleConstraints := &RuleConstraints{ + PathPattern: c.PathPattern, + } + if c.PathPattern == nil { + ruleConstraints.PathPattern = existing.PathPattern + } + if c.Permissions == nil { + ruleConstraints.Permissions = existing.Permissions + return ruleConstraints, nil + } + // Permissions are specified in the patch, need to merge them + newPermissions := make(RulePermissionMap, len(c.Permissions)+len(existing.Permissions)) + // Pre-populate newPermissions with all the non-expired existing permissions + for perm, entry := range existing.Permissions { + if !entry.Expired(currTime) { + newPermissions[perm] = entry + } + } + availablePerms, ok := interfacePermissionsAvailable[iface] + if !ok { + // Should not occur, as we should use the interface from the existing rule + return nil, prompting_errors.NewInvalidInterfaceError(iface, availableInterfaces()) + } + var errs []error + var invalidPerms []string + for perm, entry := range c.Permissions { + if !strutil.ListContains(availablePerms, perm) { + invalidPerms = append(invalidPerms, perm) + continue + } + if entry == nil { + // nil value for permission indicates that it should be removed. + // (In contrast, omitted permissions are left unchanged from the + // original constraints.) + delete(newPermissions, perm) + continue + } + ruleEntry, err := entry.toRulePermissionEntry(currTime) + if err != nil { + errs = append(errs, err) + continue + } + newPermissions[perm] = ruleEntry + } + if len(invalidPerms) > 0 { + errs = append(errs, prompting_errors.NewInvalidPermissionsError(iface, invalidPerms, availablePerms)) + } + if len(errs) > 0 { + return nil, strutil.JoinErrors(errs...) + } + ruleConstraints.Permissions = newPermissions + return ruleConstraints, nil +} + +// PermissionMap is a map from permissions to their corresponding entries, +// which contain information about the outcome and lifespan for those +// permissions. +type PermissionMap map[string]*PermissionEntry + +// toRulePermissionMap validates the receiving PermissionMap and converts it +// to a RulePermissionMap, using the given current time to convert any included +// durations to expirations. If the permission map is not valid with respect to +// the given interface, returns an error. +func (pm PermissionMap) toRulePermissionMap(iface string, currTime time.Time) (RulePermissionMap, error) { + availablePerms, ok := interfacePermissionsAvailable[iface] + if !ok { + return nil, prompting_errors.NewInvalidInterfaceError(iface, availableInterfaces()) + } + if len(pm) == 0 { + return nil, prompting_errors.NewPermissionsListEmptyError(iface, availablePerms) + } + var errs []error + var invalidPerms []string + rulePermissionMap := make(RulePermissionMap, len(pm)) + for perm, entry := range pm { + if !strutil.ListContains(availablePerms, perm) { + invalidPerms = append(invalidPerms, perm) + continue + } + rulePermissionEntry, err := entry.toRulePermissionEntry(currTime) + if err != nil { + errs = append(errs, err) + continue + } + rulePermissionMap[perm] = rulePermissionEntry + } + if len(invalidPerms) > 0 { + errs = append(errs, prompting_errors.NewInvalidPermissionsError(iface, invalidPerms, availablePerms)) + } + if len(errs) > 0 { + return nil, strutil.JoinErrors(errs...) + } + return rulePermissionMap, nil +} + +// RulePermissionMap is a map from permissions to their corresponding entries, +// which contain information about the outcome and lifespan for those +// permissions. +type RulePermissionMap map[string]*RulePermissionEntry + +// validateForInterface checks that the rule permission map is valid for the +// given interface. Any permissions which have expired relative to the given +// current time are pruned. If all permissions have expired, then returns true. +// If the permission map is invalid, returns an error. +func (pm RulePermissionMap) validateForInterface(iface string, currTime time.Time) (expired bool, err error) { + availablePerms, ok := interfacePermissionsAvailable[iface] + if !ok { + return false, prompting_errors.NewInvalidInterfaceError(iface, availableInterfaces()) + } + if len(pm) == 0 { + return false, prompting_errors.NewPermissionsListEmptyError(iface, availablePerms) + } + var errs []error + var invalidPerms []string + var expiredPerms []string + for perm, entry := range pm { + if !strutil.ListContains(availablePerms, perm) { + invalidPerms = append(invalidPerms, perm) + continue + } + if err := entry.validate(); err != nil { + errs = append(errs, err) + continue + } + if entry.Expired(currTime) { + expiredPerms = append(expiredPerms, perm) + continue + } + } + if len(invalidPerms) > 0 { + errs = append(errs, prompting_errors.NewInvalidPermissionsError(iface, invalidPerms, availablePerms)) + } + if len(errs) > 0 { + return false, strutil.JoinErrors(errs...) + } + for _, perm := range expiredPerms { + delete(pm, perm) + } + if len(pm) == 0 { + // All permissions expired + return true, nil + } + return false, nil +} + +// Expired returns true if all permissions in the map have expired. +func (pm RulePermissionMap) Expired(currTime time.Time) bool { + for _, entry := range pm { + if !entry.Expired(currTime) { return false } } return true } +// PermissionEntry holds the outcome associated with a particular permission +// and the lifespan for which that outcome is applicable. +// +// PermissionEntry is used when replying to a prompt, creating a new rule, or +// modifying an existing rule. +type PermissionEntry struct { + Outcome OutcomeType `json:"outcome"` + Lifespan LifespanType `json:"lifespan"` + Duration string `json:"duration,omitempty"` +} + +// toRulePermissionEntry validates the receiving PermissionEntry and converts +// it to a RulePermissionEntry. +// +// Checks that the entry has a valid outcome, and that its lifespan is valid +// for a rule (i.e. not LifespanSingle), and that it has an appropriate +// duration for that lifespan. The duration, combined with the given current +// time, is used to compute an expiration time, and that is returned as part +// of the corresponding RulePermissionEntry. +func (e *PermissionEntry) toRulePermissionEntry(currTime time.Time) (*RulePermissionEntry, error) { + if _, err := e.Outcome.AsBool(); err != nil { + return nil, err + } + if e.Lifespan == LifespanSingle { + // We don't allow rules with lifespan "single" + return nil, prompting_errors.NewRuleLifespanSingleError(SupportedRuleLifespans) + } + expiration, err := e.Lifespan.ParseDuration(e.Duration, currTime) + if err != nil { + return nil, err + } + rulePermissionEntry := &RulePermissionEntry{ + Outcome: e.Outcome, + Lifespan: e.Lifespan, + Expiration: expiration, + } + return rulePermissionEntry, nil +} + +// RulePermissionEntry holds the outcome associated with a particular permission +// and the lifespan for which that outcome is applicable. +// +// RulePermissionEntry is derived from a PermissionEntry after it has been used +// along with the rule's timestamp to define the expiration timeouts for any +// permissions which have a lifespan of "timespan". RulePermissionEntry is what +// is returned when retrieving rule contents, but PermissionEntry is used when +// replying to prompts, creating new rules, or modifying existing rules. +type RulePermissionEntry struct { + Outcome OutcomeType `json:"outcome"` + Lifespan LifespanType `json:"lifespan"` + Expiration time.Time `json:"expiration,omitempty"` +} + +// Expired returns true if the receiving permission entry has expired and +// should no longer be considered when matching requests. +// +// This is the case if the permission has a lifespan of timespan and the +// current time is after its expiration time. +func (e *RulePermissionEntry) Expired(currTime time.Time) bool { + switch e.Lifespan { + case LifespanTimespan: + if !currTime.Before(e.Expiration) { + return true + } + // TODO: add lifespan session + //case LifespanSession: + // TODO: return true if the user session has changed + } + return false +} + +// validate checks that the entry has a valid outcome, and that its lifespan +// is valid for a rule (i.e. not LifespanSingle), and has an appropriate +// expiration for that lifespan. +func (e *RulePermissionEntry) validate() error { + if _, err := e.Outcome.AsBool(); err != nil { + return err + } + if e.Lifespan == LifespanSingle { + // We don't allow rules with lifespan "single" + return prompting_errors.NewRuleLifespanSingleError(SupportedRuleLifespans) + } + if err := e.Lifespan.ValidateExpiration(e.Expiration); err != nil { + // Should never error due to an API request, since rules are always + // added via the API using duration, rather than expiration. + // Error may occur when validating a rule loaded from disk. + // We don't check expiration as part of validation. + return err + } + return nil +} + var ( // List of permissions available for each interface. This also defines the // order in which the permissions should be presented. diff --git a/interfaces/prompting/constraints_test.go b/interfaces/prompting/constraints_test.go index f2f80ca6d82..2511fb0f16e 100644 --- a/interfaces/prompting/constraints_test.go +++ b/interfaces/prompting/constraints_test.go @@ -20,9 +20,13 @@ package prompting_test import ( + "fmt" + "time" + . "gopkg.in/check.v1" "github.com/snapcore/snapd/interfaces/prompting" + prompting_errors "github.com/snapcore/snapd/interfaces/prompting/errors" "github.com/snapcore/snapd/interfaces/prompting/patterns" "github.com/snapcore/snapd/logger" "github.com/snapcore/snapd/sandbox/apparmor/notify" @@ -33,126 +37,13 @@ type constraintsSuite struct{} var _ = Suite(&constraintsSuite{}) -func (s *constraintsSuite) TestConstraintsValidateForInterface(c *C) { - validPathPattern, err := patterns.ParsePathPattern("/path/to/foo") +func mustParsePathPattern(c *C, patternStr string) *patterns.PathPattern { + pattern, err := patterns.ParsePathPattern(patternStr) c.Assert(err, IsNil) - - // Happy - constraints := &prompting.Constraints{ - PathPattern: validPathPattern, - Permissions: []string{"read"}, - } - err = constraints.ValidateForInterface("home") - c.Check(err, IsNil) - - // Bad interface or permissions - cases := []struct { - iface string - perms []string - errStr string - }{ - { - "foo", - []string{"read"}, - `invalid interface: "foo"`, - }, - { - "home", - []string{}, - "invalid permissions for home interface: permissions list empty", - }, - { - "home", - []string{"access"}, - "invalid permissions for home interface.*", - }, - } - for _, testCase := range cases { - constraints := &prompting.Constraints{ - PathPattern: validPathPattern, - Permissions: testCase.perms, - } - err = constraints.ValidateForInterface(testCase.iface) - c.Check(err, ErrorMatches, testCase.errStr) - } - - // Check missing path pattern - constraints = &prompting.Constraints{ - Permissions: []string{"read"}, - } - err = constraints.ValidateForInterface("home") - c.Check(err, ErrorMatches, `invalid path pattern: no path pattern: ""`) -} - -func (s *constraintsSuite) TestValidatePermissionsHappy(c *C) { - cases := []struct { - iface string - initial []string - final []string - }{ - { - "home", - []string{"write", "read", "execute"}, - []string{"read", "write", "execute"}, - }, - { - "home", - []string{"execute", "write", "read"}, - []string{"read", "write", "execute"}, - }, - { - "home", - []string{"write", "write", "write"}, - []string{"write"}, - }, - } - for _, testCase := range cases { - constraints := prompting.Constraints{ - Permissions: testCase.initial, - } - err := constraints.ValidatePermissions(testCase.iface) - c.Check(err, IsNil, Commentf("testCase: %+v", testCase)) - c.Check(constraints.Permissions, DeepEquals, testCase.final, Commentf("testCase: %+v", testCase)) - } -} - -func (s *constraintsSuite) TestValidatePermissionsUnhappy(c *C) { - cases := []struct { - iface string - perms []string - errStr string - }{ - { - "foo", - []string{"read"}, - `invalid interface: "foo"`, - }, - { - "home", - []string{"access"}, - `invalid permissions for home interface: "access"`, - }, - { - "home", - []string{"read", "write", "access"}, - `invalid permissions for home interface: "access"`, - }, - { - "home", - []string{}, - "invalid permissions for home interface: permissions list empty", - }, - } - for _, testCase := range cases { - constraints := prompting.Constraints{ - Permissions: testCase.perms, - } - err := constraints.ValidatePermissions(testCase.iface) - c.Check(err, ErrorMatches, testCase.errStr, Commentf("testCase: %+v", testCase)) - } + return pattern } -func (*constraintsSuite) TestConstraintsMatch(c *C) { +func (s *constraintsSuite) TestConstraintsMatch(c *C) { cases := []struct { pattern string path string @@ -170,26 +61,40 @@ func (*constraintsSuite) TestConstraintsMatch(c *C) { }, } for _, testCase := range cases { - pattern, err := patterns.ParsePathPattern(testCase.pattern) - c.Check(err, IsNil) + pattern := mustParsePathPattern(c, testCase.pattern) + constraints := &prompting.Constraints{ PathPattern: pattern, - Permissions: []string{"read"}, } result, err := constraints.Match(testCase.path) c.Check(err, IsNil, Commentf("test case: %+v", testCase)) c.Check(result, Equals, testCase.matches, Commentf("test case: %+v", testCase)) + + ruleConstraints := &prompting.RuleConstraints{ + PathPattern: pattern, + } + result, err = ruleConstraints.Match(testCase.path) + c.Check(err, IsNil, Commentf("test case: %+v", testCase)) + c.Check(result, Equals, testCase.matches, Commentf("test case: %+v", testCase)) } } func (s *constraintsSuite) TestConstraintsMatchUnhappy(c *C) { badPath := `bad\path\` + badConstraints := &prompting.Constraints{ - Permissions: []string{"read"}, + PathPattern: nil, } matches, err := badConstraints.Match(badPath) c.Check(err, ErrorMatches, `invalid path pattern: no path pattern: ""`) c.Check(matches, Equals, false) + + badRuleConstraints := &prompting.RuleConstraints{ + PathPattern: nil, + } + matches, err = badRuleConstraints.Match(badPath) + c.Check(err, ErrorMatches, `invalid path pattern: no path pattern: ""`) + c.Check(matches, Equals, false) } func (s *constraintsSuite) TestConstraintsContainPermissions(c *C) { @@ -240,17 +145,781 @@ func (s *constraintsSuite) TestConstraintsContainPermissions(c *C) { }, } for _, testCase := range cases { - pathPattern, err := patterns.ParsePathPattern("/arbitrary") - c.Check(err, IsNil) + pathPattern := mustParsePathPattern(c, "/arbitrary") constraints := &prompting.Constraints{ PathPattern: pathPattern, - Permissions: testCase.constPerms, + Permissions: make(prompting.PermissionMap), + } + fakeEntry := &prompting.PermissionEntry{} + for _, perm := range testCase.constPerms { + constraints.Permissions[perm] = fakeEntry } contained := constraints.ContainPermissions(testCase.queryPerms) c.Check(contained, Equals, testCase.contained, Commentf("testCase: %+v", testCase)) } } +func (s *constraintsSuite) TestConstraintsToRuleConstraintsHappy(c *C) { + currTime := time.Now() + iface := "home" + pathPattern := mustParsePathPattern(c, "/path/to/{foo,*or*,bar}{,/}**") + + constraints := &prompting.Constraints{ + PathPattern: pathPattern, + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Duration: "10s", + }, + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Duration: "1ns", + }, + }, + } + + expectedRuleConstraints := &prompting.RuleConstraints{ + PathPattern: pathPattern, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(10 * time.Second), + }, + "execute": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(time.Nanosecond), + }, + }, + } + + result, err := constraints.ToRuleConstraints(iface, currTime) + c.Assert(err, IsNil) + c.Assert(result, DeepEquals, expectedRuleConstraints) +} + +func (s *constraintsSuite) TestConstraintsToRuleConstraintsUnhappy(c *C) { + badConstraints := &prompting.Constraints{} + result, err := badConstraints.ToRuleConstraints("home", time.Now()) + c.Check(result, IsNil) + c.Check(err, ErrorMatches, `invalid path pattern: no path pattern.*`) + + constraints := &prompting.Constraints{ + PathPattern: mustParsePathPattern(c, "/path/to/foo"), + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + result, err = constraints.ToRuleConstraints("foo", time.Now()) + c.Check(result, IsNil) + c.Check(err, ErrorMatches, `invalid interface: "foo"`) + + for _, testCase := range []struct { + perms prompting.PermissionMap + errStr string + }{ + { + perms: nil, + errStr: `invalid permissions for home interface: permissions list empty`, + }, + { + perms: prompting.PermissionMap{ + "create": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + errStr: `invalid permissions for home interface: "create"`, + }, + { + perms: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + }, + }, + errStr: `invalid duration: cannot have unspecified duration when lifespan is "timespan".*`, + }, + { + perms: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + }, + "create": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + errStr: joinErrorsUnordered(`invalid duration: cannot have unspecified duration when lifespan is "timespan": ""`, `invalid permissions for home interface: "create"`), + }, + } { + constraints := &prompting.Constraints{ + PathPattern: mustParsePathPattern(c, "/path/to/foo"), + Permissions: testCase.perms, + } + result, err = constraints.ToRuleConstraints("home", time.Now()) + c.Check(result, IsNil, Commentf("testCase: %+v", testCase)) + c.Check(err, ErrorMatches, testCase.errStr, Commentf("testCase: %+v", testCase)) + } +} + +func joinErrorsUnordered(err1, err2 string) string { + return fmt.Sprintf("(%s\n%s|%s\n%s)", err1, err2, err2, err1) +} + +func (s *constraintsSuite) TestRuleConstraintsValidateForInterface(c *C) { + validPathPattern := mustParsePathPattern(c, "/path/to/foo") + currTime := time.Now() + + // Happy + constraints := &prompting.RuleConstraints{ + PathPattern: validPathPattern, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(time.Second), + }, + }, + } + expired, err := constraints.ValidateForInterface("home", currTime) + c.Check(err, IsNil) + c.Check(expired, Equals, false) + + // Bad interface or permissions + cases := []struct { + iface string + perms prompting.RulePermissionMap + errStr string + }{ + { + "foo", + prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + prompting_errors.NewInvalidInterfaceError("foo", nil).Error(), + }, + { + "home", + prompting.RulePermissionMap{}, + prompting_errors.NewPermissionsListEmptyError("home", nil).Error(), + }, + { + "home", + prompting.RulePermissionMap{ + "access": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + prompting_errors.NewInvalidPermissionsError("home", []string{"access"}, []string{"read", "write", "execute"}).Error(), + }, + { + "home", + prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + Expiration: time.Now().Add(time.Second), + }, + }, + "invalid expiration: cannot have specified expiration.*", + }, + { + "home", + prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanSingle, + }, + }, + `cannot create rule with lifespan "single"`, + }, + { + "home", + prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeType("bar"), + Lifespan: prompting.LifespanTimespan, + Expiration: time.Now().Add(-time.Second), + }, + }, + `invalid outcome: "bar"`, + }, + } + for _, testCase := range cases { + constraints := &prompting.RuleConstraints{ + PathPattern: validPathPattern, + Permissions: testCase.perms, + } + expired, err = constraints.ValidateForInterface(testCase.iface, time.Now()) + c.Check(err, ErrorMatches, testCase.errStr) + c.Check(expired, Equals, false) + } + + // Check missing path pattern + constraints = &prompting.RuleConstraints{ + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + _, err = constraints.ValidateForInterface("home", time.Now()) + c.Check(err, ErrorMatches, `invalid path pattern: no path pattern: ""`) +} + +func (s *constraintsSuite) TestRuleConstraintsValidateForInterfaceExpiration(c *C) { + pathPattern := mustParsePathPattern(c, "/path/to/foo") + currTime := time.Now() + + for _, testCase := range []struct { + perms prompting.RulePermissionMap + expired bool + expected prompting.RulePermissionMap + }{ + { + prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + false, + prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + }, + { + prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime, + }, + }, + true, + prompting.RulePermissionMap{}, + }, + { + prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(-time.Minute), + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(time.Minute), + }, + "execute": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime, + }, + }, + false, + prompting.RulePermissionMap{ + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(time.Minute), + }, + }, + }, + { + prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(-time.Minute), + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime, + }, + "execute": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime, + }, + }, + true, + prompting.RulePermissionMap{}, + }, + { + prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(-time.Minute), + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(time.Minute), + }, + "execute": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, + false, + prompting.RulePermissionMap{ + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(time.Minute), + }, + "execute": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, + }, + } { + copiedPerms := make(prompting.RulePermissionMap, len(testCase.perms)) + for perm, entry := range testCase.perms { + copiedPerms[perm] = entry + } + constraints := &prompting.RuleConstraints{ + PathPattern: pathPattern, + Permissions: copiedPerms, + } + expired, err := constraints.ValidateForInterface("home", currTime) + c.Check(err, IsNil) + c.Check(expired, Equals, testCase.expired, Commentf("testCase: %+v\nremaining perms: %+v", testCase, constraints.Permissions)) + c.Check(constraints.Permissions, DeepEquals, testCase.expected, Commentf("testCase: %+v\nremaining perms: %+v", testCase, constraints.Permissions)) + } +} + +func (s *constraintsSuite) TestReplyConstraintsToConstraintsHappy(c *C) { + iface := "home" + pathPattern := mustParsePathPattern(c, "/path/to/dir/{foo*,ba?/**}") + + for _, testCase := range []struct { + pathPattern *patterns.PathPattern + permissions []string + outcome prompting.OutcomeType + lifespan prompting.LifespanType + duration string + expected *prompting.Constraints + }{ + { + permissions: []string{"read", "write", "execute"}, + outcome: prompting.OutcomeAllow, + lifespan: prompting.LifespanForever, + expected: &prompting.Constraints{ + PathPattern: pathPattern, + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + }, + }, + { + permissions: []string{"write", "read"}, + outcome: prompting.OutcomeDeny, + lifespan: prompting.LifespanTimespan, + duration: "10m", + expected: &prompting.Constraints{ + PathPattern: pathPattern, + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Duration: "10m", + }, + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Duration: "10m", + }, + }, + }, + }, + } { + replyConstraints := &prompting.ReplyConstraints{ + PathPattern: pathPattern, + Permissions: testCase.permissions, + } + constraints, err := replyConstraints.ToConstraints(iface, testCase.outcome, testCase.lifespan, testCase.duration) + c.Check(err, IsNil) + c.Check(constraints, DeepEquals, testCase.expected) + } +} + +func (s *constraintsSuite) TestReplyConstraintsToConstraintsUnhappy(c *C) { + for _, testCase := range []struct { + nilPattern bool + permissions []string + iface string + outcome prompting.OutcomeType + lifespan prompting.LifespanType + duration string + errStr string + }{ + { + outcome: prompting.OutcomeType("foo"), + errStr: `invalid outcome: "foo"`, + }, + { + lifespan: prompting.LifespanTimespan, + duration: "", + errStr: `invalid duration: cannot have unspecified duration when lifespan is "timespan":.*`, + }, + { + lifespan: prompting.LifespanForever, + duration: "10s", + errStr: `invalid duration: cannot have specified duration when lifespan is "forever":.*`, + }, + { + nilPattern: true, + errStr: `invalid path pattern: no path pattern: ""`, + }, + { + iface: "foo", + errStr: `invalid interface: "foo"`, + }, + { + permissions: make([]string, 0), + errStr: `invalid permissions for home interface: permissions list empty`, + }, + { + permissions: []string{"read", "append", "write", "create", "execute"}, + errStr: `invalid permissions for home interface: "append", "create"`, + }, + } { + replyConstraints := &prompting.ReplyConstraints{ + PathPattern: mustParsePathPattern(c, "/path/to/foo"), + Permissions: []string{"read", "write", "execute"}, + } + if testCase.nilPattern { + replyConstraints.PathPattern = nil + } + if testCase.permissions != nil { + replyConstraints.Permissions = testCase.permissions + } + iface := "home" + if testCase.iface != "" { + iface = testCase.iface + } + outcome := prompting.OutcomeAllow + if testCase.outcome != prompting.OutcomeUnset { + outcome = testCase.outcome + } + lifespan := prompting.LifespanForever + if testCase.lifespan != prompting.LifespanUnset { + lifespan = testCase.lifespan + } + duration := "" + if testCase.duration != "" { + duration = testCase.duration + } + result, err := replyConstraints.ToConstraints(iface, outcome, lifespan, duration) + c.Check(result, IsNil, Commentf("testCase: %+v", testCase)) + c.Check(err, ErrorMatches, testCase.errStr, Commentf("testCase: %+v", testCase)) + } +} + +func (s *constraintsSuite) TestPatchRuleConstraintsHappy(c *C) { + origTime := time.Now() + patchTime := origTime.Add(time.Second) + iface := "home" + + pathPattern := mustParsePathPattern(c, "/path/to/foo/ba?/**") + + for i, testCase := range []struct { + initial *prompting.RuleConstraints + patch *prompting.RuleConstraintsPatch + final *prompting.RuleConstraints + }{ + { + initial: &prompting.RuleConstraints{ + PathPattern: pathPattern, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: origTime, + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: origTime.Add(-time.Second), + }, + }, + }, + patch: &prompting.RuleConstraintsPatch{}, + final: &prompting.RuleConstraints{ + PathPattern: pathPattern, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: origTime, + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: origTime.Add(-time.Second), // expired perms are not pruned if patch perms are nil + }, + }, + }, + }, + { + initial: &prompting.RuleConstraints{ + PathPattern: pathPattern, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: patchTime.Add(time.Second), + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: origTime, + }, + }, + }, + patch: &prompting.RuleConstraintsPatch{ + Permissions: prompting.PermissionMap{ + "write": nil, + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Duration: "1m", + }, + }, + }, + final: &prompting.RuleConstraints{ + PathPattern: pathPattern, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: patchTime.Add(time.Second), + }, + "execute": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: patchTime.Add(time.Minute), + }, + }, + }, + }, + { + initial: &prompting.RuleConstraints{ + PathPattern: pathPattern, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: patchTime.Add(time.Second), + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: origTime, + }, + "execute": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + }, + patch: &prompting.RuleConstraintsPatch{ + Permissions: prompting.PermissionMap{ + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + }, + final: &prompting.RuleConstraints{ + PathPattern: pathPattern, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: patchTime.Add(time.Second), + }, + "execute": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + }, + }, + } { + patched, err := testCase.patch.PatchRuleConstraints(testCase.initial, iface, patchTime) + c.Check(err, IsNil, Commentf("testCase %d", i)) + c.Check(patched, DeepEquals, testCase.final, Commentf("testCase %d", i)) + } +} + +func (s *constraintsSuite) TestPatchRuleConstraintsUnhappy(c *C) { + origTime := time.Now() + patchTime := origTime.Add(time.Second) + iface := "home" + + pathPattern := mustParsePathPattern(c, "/path/to/foo/ba{r,z{,/**/}}") + + goodRule := &prompting.RuleConstraints{ + PathPattern: pathPattern, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: patchTime.Add(time.Second), + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: origTime, + }, + }, + } + goodPatch := &prompting.RuleConstraintsPatch{ + Permissions: prompting.PermissionMap{ + "write": nil, + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, + } + + badIface := "foo" + result, err := goodPatch.PatchRuleConstraints(goodRule, badIface, patchTime) + c.Check(err, ErrorMatches, `invalid interface: "foo"`) + c.Check(result, IsNil) + + badPatch := &prompting.RuleConstraintsPatch{ + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanSingle, + }, + "create": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "lock": nil, // even if invalid permission is meant to be removed, include it + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + }, + }, + } + expected := joinErrorsUnordered(`invalid duration: cannot have unspecified duration when lifespan is "timespan": ""`, `cannot create rule with lifespan "single"`) + "\n" + `invalid permissions for home interface: ("create", "lock"|"lock", "create")` + + result, err = badPatch.PatchRuleConstraints(goodRule, iface, patchTime) + c.Check(result, IsNil) + c.Check(err, ErrorMatches, expected) +} + +func (s *constraintsSuite) TestRulePermissionMapExpired(c *C) { + currTime := time.Now() + for _, pm := range []prompting.RulePermissionMap{ + {}, + { + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime, + }, + }, + { + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(-time.Second), + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime, + }, + }, + } { + c.Check(pm.Expired(currTime), Equals, true, Commentf("%+v", pm)) + } + + for _, pm := range []prompting.RulePermissionMap{ + { + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(-time.Second), + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, + { + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(-time.Second), + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, + { + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Expiration: currTime.Add(time.Second), + }, + }, + } { + c.Check(pm.Expired(currTime), Equals, false, Commentf("%+v", pm)) + } +} + func constructPermissionsMaps() []map[string]map[string]any { var permissionsMaps []map[string]map[string]any // interfaceFilePermissionsMaps diff --git a/interfaces/prompting/errors/errors.go b/interfaces/prompting/errors/errors.go index e2d80015258..2ad388220c0 100644 --- a/interfaces/prompting/errors/errors.go +++ b/interfaces/prompting/errors/errors.go @@ -51,9 +51,8 @@ var ( ErrRuleDBInconsistent = errors.New("internal error: interfaces requests rule database left inconsistent") // Errors which are used internally and should never be returned over the API - ErrNoMatchingRule = errors.New("no rule matches the given path") - ErrInvalidID = errors.New("invalid ID: format must be parsable as uint64") - ErrRuleExpirationInThePast = errors.New("cannot have expiration time in the past") + ErrNoMatchingRule = errors.New("no rule matches the given path") + ErrInvalidID = errors.New("invalid ID: format must be parsable as uint64") ) // Marker for UnsupportedValueError, should never be returned as an actual @@ -125,6 +124,9 @@ func NewInvalidPermissionsError(iface string, unsupported []string, supported [] } func NewPermissionsListEmptyError(iface string, supported []string) *UnsupportedValueError { + // TODO: change language to "permissions empty" rather than "permissions list empty", + // since permissions now come as a list in prompt replies but as a map when creating + // or modifying rules directly. return &UnsupportedValueError{ Field: "permissions", Msg: fmt.Sprintf("invalid permissions for %s interface: permissions list empty", iface), diff --git a/interfaces/prompting/export_test.go b/interfaces/prompting/export_test.go index 943d068930a..6a6ffa98f12 100644 --- a/interfaces/prompting/export_test.go +++ b/interfaces/prompting/export_test.go @@ -23,7 +23,3 @@ var ( InterfacePermissionsAvailable = interfacePermissionsAvailable InterfaceFilePermissionsMaps = interfaceFilePermissionsMaps ) - -func (c *Constraints) ValidatePermissions(iface string) error { - return c.validatePermissions(iface) -} diff --git a/interfaces/prompting/prompting.go b/interfaces/prompting/prompting.go index eafabbdecc5..3049a00e5e5 100644 --- a/interfaces/prompting/prompting.go +++ b/interfaces/prompting/prompting.go @@ -159,10 +159,9 @@ func (lifespan *LifespanType) UnmarshalJSON(data []byte) error { // ValidateExpiration checks that the given expiration is valid for the // receiver lifespan. // -// If the lifespan is LifespanTimespan, then expiration must be non-zero and be -// after the given currTime. Otherwise, it must be zero. Returns an error if -// any of the above are invalid. -func (lifespan LifespanType) ValidateExpiration(expiration time.Time, currTime time.Time) error { +// If the lifespan is LifespanTimespan, then expiration must be non-zero. +// Otherwise, it must be zero. Returns an error if any of the above are invalid. +func (lifespan LifespanType) ValidateExpiration(expiration time.Time) error { switch lifespan { case LifespanForever, LifespanSingle: if !expiration.IsZero() { @@ -172,9 +171,6 @@ func (lifespan LifespanType) ValidateExpiration(expiration time.Time, currTime t if expiration.IsZero() { return prompting_errors.NewInvalidExpirationError(expiration, fmt.Sprintf("cannot have unspecified expiration when lifespan is %q", lifespan)) } - if currTime.After(expiration) { - return fmt.Errorf("%w: %q", prompting_errors.ErrRuleExpirationInThePast, expiration) - } default: // Should not occur, since lifespan is validated when unmarshalled return prompting_errors.NewInvalidLifespanError(string(lifespan), supportedLifespans) diff --git a/interfaces/prompting/prompting_test.go b/interfaces/prompting/prompting_test.go index edcf8b68ddf..bcb27b8d960 100644 --- a/interfaces/prompting/prompting_test.go +++ b/interfaces/prompting/prompting_test.go @@ -169,21 +169,18 @@ func (s *promptingSuite) TestValidateExpiration(c *C) { prompting.LifespanForever, prompting.LifespanSingle, } { - err := lifespan.ValidateExpiration(unsetExpiration, currTime) + err := lifespan.ValidateExpiration(unsetExpiration) c.Check(err, IsNil) for _, exp := range []time.Time{negativeExpiration, validExpiration} { - err = lifespan.ValidateExpiration(exp, currTime) + err = lifespan.ValidateExpiration(exp) c.Check(err, ErrorMatches, `invalid expiration: cannot have specified expiration when lifespan is.*`) } } - err := prompting.LifespanTimespan.ValidateExpiration(unsetExpiration, currTime) + err := prompting.LifespanTimespan.ValidateExpiration(unsetExpiration) c.Check(err, ErrorMatches, `invalid expiration: cannot have unspecified expiration when lifespan is.*`) - err = prompting.LifespanTimespan.ValidateExpiration(negativeExpiration, currTime) - c.Check(err, ErrorMatches, `cannot have expiration time in the past.*`) - - err = prompting.LifespanTimespan.ValidateExpiration(validExpiration, currTime) + err = prompting.LifespanTimespan.ValidateExpiration(validExpiration) c.Check(err, IsNil) } diff --git a/interfaces/prompting/requestprompts/requestprompts.go b/interfaces/prompting/requestprompts/requestprompts.go index 5952ecd3548..281c3df16ae 100644 --- a/interfaces/prompting/requestprompts/requestprompts.go +++ b/interfaces/prompting/requestprompts/requestprompts.go @@ -100,41 +100,22 @@ func (p *Prompt) MarshalJSON() ([]byte, error) { } func (p *Prompt) sendReply(outcome prompting.OutcomeType) error { + allow, err := outcome.AsBool() + if err != nil { + // This should not occur + return err + } // Reply with any permissions which were previously allowed // If outcome is allow, then reply by allowing all originally-requested // permissions. If outcome is deny, only allow permissions which were // originally requested but have since been allowed by rules, and deny any // remaining permissions. - allowedPermission := responseForInterfaceConstraintsOutcome(p.Interface, p.Constraints, outcome) - return p.sendReplyWithPermission(allowedPermission) -} - -func responseForInterfaceConstraintsOutcome(iface string, constraints *promptConstraints, outcome prompting.OutcomeType) any { - allow, err := outcome.AsBool() - if err != nil { - // This should not occur, but if so, default to deny - allow = false - logger.Noticef("internal error: failed to compute prompting outcome: %v", err) - } - allowedPerms := constraints.originalPermissions + var deniedPermissions []string if !allow { - // Remaining permissions were denied, so allow permissions which were - // previously allowed by prompt rules - allowedPerms = make([]string, 0, len(constraints.originalPermissions)-len(constraints.remainingPermissions)) - for _, perm := range constraints.originalPermissions { - // Exclude any permissions which were remaining at time of denial - if !strutil.ListContains(constraints.remainingPermissions, perm) { - allowedPerms = append(allowedPerms, perm) - } - } - } - allowedPermission, err := prompting.AbstractPermissionsToAppArmorPermissions(iface, allowedPerms) - if err != nil { - // This should not occur, but if so, permission should be set to the - // empty value for its corresponding permission type. - logger.Noticef("internal error: cannot convert abstract permissions to AppArmor permissions: %v", err) + deniedPermissions = p.Constraints.remainingPermissions } - return allowedPermission + allowedPermission := p.Constraints.buildResponse(p.Interface, deniedPermissions) + return p.sendReplyWithPermission(allowedPermission) } func (p *Prompt) sendReplyWithPermission(allowedPermission any) error { @@ -195,20 +176,91 @@ func (pc *promptConstraints) equals(other *promptConstraints) bool { return true } -// subtractPermissions removes all of the given permissions from the list of -// permissions in the constraints. -func (pc *promptConstraints) subtractPermissions(permissions []string) (modified bool) { - newPermissions := make([]string, 0, len(pc.remainingPermissions)) +// applyRuleConstraints modifies the prompt constraints, removing any remaining +// permissions which are matched by the given rule constraints. +// +// Returns whether the prompt constraints were affected by the rule constraints, +// whether the prompt requires a response (either because all permissions were +// allowed or at least one permission was denied), and the list of any +// permissions which were denied. If an error occurs, it is returned, and the +// other return values can be ignored. +// +// If the path pattern does not match the prompt path, or the permissions in +// the rule constraints do not include any of the remaining prompt permissions, +// then affectedByRule is false, and no changes are made to the prompt +// constraints. +func (pc *promptConstraints) applyRuleConstraints(constraints *prompting.RuleConstraints) (affectedByRule, respond bool, deniedPermissions []string, err error) { + pathMatched, err := constraints.Match(pc.path) + if err != nil { + // Should not occur, only error is if path pattern is malformed, + // which would have thrown an error while parsing, not now. + return false, false, nil, err + } + if !pathMatched { + return false, false, nil, nil + } + + // Path pattern matched, now check if any permissions match + + newRemainingPermissions := make([]string, 0, len(pc.remainingPermissions)) for _, perm := range pc.remainingPermissions { - if !strutil.ListContains(permissions, perm) { - newPermissions = append(newPermissions, perm) + entry, exists := constraints.Permissions[perm] + if !exists { + // Permission not covered by rule constraints, so permission + // should continue to be in remainingPermissions. + newRemainingPermissions = append(newRemainingPermissions, perm) + continue + } + affectedByRule = true + allow, err := entry.Outcome.AsBool() + if err != nil { + // This should not occur, as rule constraints are built internally + return false, false, nil, err } + if !allow { + deniedPermissions = append(deniedPermissions, perm) + } + } + if !affectedByRule { + // No permissions matched, so nothing changes, no need to record a + // notice or send a response. + return false, false, nil, nil + } + + pc.remainingPermissions = newRemainingPermissions + + if len(pc.remainingPermissions) == 0 || len(deniedPermissions) > 0 { + // All permissions allowed or at least one permission denied, so tell + // the caller to send a response back to the kernel. + respond = true } - if len(newPermissions) != len(pc.remainingPermissions) { - pc.remainingPermissions = newPermissions - return true + + return affectedByRule, respond, deniedPermissions, nil +} + +// buildResponse creates a listener response to the receiving prompt constraints +// based on the given interface and list of denied permissions. +// +// The response is the AppArmor permission which should be allowed. This +// corresponds to the originally requested permissions from the prompt +// constraints, except with all denied permissions removed. +func (pc *promptConstraints) buildResponse(iface string, deniedPermissions []string) any { + allowedPerms := pc.originalPermissions + if len(deniedPermissions) > 0 { + allowedPerms = make([]string, 0, len(pc.originalPermissions)-len(deniedPermissions)) + for _, perm := range pc.originalPermissions { + if !strutil.ListContains(deniedPermissions, perm) { + allowedPerms = append(allowedPerms, perm) + } + } + } + allowedPermission, err := prompting.AbstractPermissionsToAppArmorPermissions(iface, allowedPerms) + if err != nil { + // This should not occur, but if so, permission should be set to the + // empty value for its corresponding permission type. + logger.Noticef("internal error: cannot convert abstract permissions to AppArmor permissions: %v", err) } - return false + return allowedPermission } // Path returns the path associated with the request to which the receiving @@ -434,7 +486,8 @@ func (pdb *PromptDB) AddOrMerge(metadata *prompting.Metadata, path string, reque if len(userEntry.prompts) >= maxOutstandingPromptsPerUser { logger.Noticef("WARNING: too many outstanding prompts for user %d; auto-denying new one", metadata.User) - allowedPermission := responseForInterfaceConstraintsOutcome(metadata.Interface, constraints, prompting.OutcomeDeny) + // Deny all permissions which are not already allowed by existing rules + allowedPermission := constraints.buildResponse(metadata.Interface, constraints.remainingPermissions) sendReply(listenerReq, allowedPermission) return nil, false, prompting_errors.ErrTooManyPrompts } @@ -541,10 +594,10 @@ func (pdb *PromptDB) Reply(user uint32, id prompting.IDType, outcome prompting.O // contents and, if so, sends back a decision to their listener requests. // // A prompt is satisfied by the given rule contents if the user, snap, -// interface, and path of the prompt match those of the rule, and if either the -// outcome is "allow" and all of the prompt's permissions are matched by those -// of the rule contents, or if the outcome is "deny" and any of the permissions -// match. +// interface, and path of the prompt match those of the rule, and all remaining +// permissions are covered by permissions in the rule constraints or at least +// one of the remaining permissions is covered by a permission which has an +// outcome of "deny". // // Records a notice for any prompt which was satisfied, or which had some of // its permissions satisfied by the rule contents. In the future, only the @@ -553,12 +606,11 @@ func (pdb *PromptDB) Reply(user uint32, id prompting.IDType, outcome prompting.O // // Returns the IDs of any prompts which were fully satisfied by the given rule // contents. -func (pdb *PromptDB) HandleNewRule(metadata *prompting.Metadata, constraints *prompting.Constraints, outcome prompting.OutcomeType) ([]prompting.IDType, error) { - // Validate outcome before locking - allow, err := outcome.AsBool() - if err != nil { - return nil, err - } +// +// Since rule is new, we don't check the expiration timestamps for any +// permissions, since any permissions with lifespan timespan were validated to +// have a non-zero duration, and we handle this rule as it was at its creation. +func (pdb *PromptDB) HandleNewRule(metadata *prompting.Metadata, constraints *prompting.RuleConstraints) ([]prompting.IDType, error) { pdb.mutex.Lock() defer pdb.mutex.Unlock() @@ -575,35 +627,50 @@ func (pdb *PromptDB) HandleNewRule(metadata *prompting.Metadata, constraints *pr if !(prompt.Snap == metadata.Snap && prompt.Interface == metadata.Interface) { continue } - matched, err := constraints.Match(prompt.Constraints.path) + + affectedByRule, respond, deniedPermissions, err := prompt.Constraints.applyRuleConstraints(constraints) if err != nil { + // Should not occur, only error is if path pattern is malformed, + // which would have thrown an error while parsing, not now. return nil, err } - if !matched { + if !affectedByRule { continue } - - // Record all allowed permissions at the time of match, in case a - // permission is denied and we need to send back a response. - allowedPermission := responseForInterfaceConstraintsOutcome(metadata.Interface, prompt.Constraints, outcome) - - // See if the permission matches any of the prompt's remaining permissions - modified := prompt.Constraints.subtractPermissions(constraints.Permissions) - if !modified { - // No permission was matched + if !respond { + // No response necessary, though the prompt constraints were + // modified, so just record a notice for the prompt. + pdb.notifyPrompt(metadata.User, prompt.ID, nil) continue } - id := prompt.ID - if len(prompt.Constraints.remainingPermissions) > 0 && allow == true { - pdb.notifyPrompt(metadata.User, id, nil) - continue + + // A response is necessary, so either all permissions were allowed or + // at least one permission was denied. Construct a response and send it + // back to the kernel, and record a notice that the prompt was satisfied. + if len(deniedPermissions) > 0 { + // At least one permission was denied by new rule, and we want to + // send a response immediately, so include any remaining + // permissions as denied as well. + // + // This could be done as part of applyRuleConstraints instead, but + // it seems semantically clearer to only return the permissions + // which were explicitly denied by the rule, rather than all + // remaining permissions because at least one was denied. It's the + // prorogative of the caller (this function) to treat the remaining + // permissions as denied since we want to send a response without + // waiting for future rules to satisfy the remaining permissions. + deniedPermissions = append(deniedPermissions, prompt.Constraints.remainingPermissions...) } - // All permissions of prompt satisfied, or any permission denied + // Build and send a response with any permissions which were allowed, + // either by this new rule or by previous rules. + allowedPermission := prompt.Constraints.buildResponse(metadata.Interface, deniedPermissions) prompt.sendReplyWithPermission(allowedPermission) - userEntry.remove(id) - satisfiedPromptIDs = append(satisfiedPromptIDs, id) + // Now that a response has been sent, remove the rule from the rule DB + // and record a notice indicating that it has been satisfied. + userEntry.remove(prompt.ID) + satisfiedPromptIDs = append(satisfiedPromptIDs, prompt.ID) data := map[string]string{"resolved": "satisfied"} - pdb.notifyPrompt(metadata.User, id, data) + pdb.notifyPrompt(metadata.User, prompt.ID, data) } return satisfiedPromptIDs, nil } diff --git a/interfaces/prompting/requestprompts/requestprompts_test.go b/interfaces/prompting/requestprompts/requestprompts_test.go index f57a817773d..b14d28dba99 100644 --- a/interfaces/prompting/requestprompts/requestprompts_test.go +++ b/interfaces/prompting/requestprompts/requestprompts_test.go @@ -20,6 +20,7 @@ package requestprompts_test import ( + "bytes" "encoding/json" "fmt" "os" @@ -350,7 +351,19 @@ func applyNotices(expectedPromptIDs []prompting.IDType, expectedData map[string] } func (s *requestpromptsSuite) checkNewNotices(c *C, expectedNotices []*noticeInfo) { - c.Check(s.promptNotices, DeepEquals, expectedNotices) + c.Check(s.promptNotices, DeepEquals, expectedNotices, Commentf("%s", func() string { + var buf bytes.Buffer + buf.WriteString("\nobtained: [\n") + for _, n := range s.promptNotices { + buf.WriteString(fmt.Sprintf(" %+v\n", n)) + } + buf.WriteString("]\nexpected: [\n") + for _, n := range expectedNotices { + buf.WriteString(fmt.Sprintf(" %+v\n", n)) + } + buf.WriteString("]\n") + return buf.String() + }())) s.promptNotices = s.promptNotices[:0] } @@ -613,7 +626,7 @@ func (s *requestpromptsSuite) TestReplyErrors(c *C) { s.checkNewNoticesSimple(c, []prompting.IDType{}, nil) } -func (s *requestpromptsSuite) TestHandleNewRuleAllowPermissions(c *C) { +func (s *requestpromptsSuite) TestHandleNewRule(c *C) { listenerReqChan := make(chan *listener.Request, 2) replyChan := make(chan any, 2) restore := requestprompts.MockSendReply(func(listenerReq *listener.Request, allowedPermission any) error { @@ -667,23 +680,26 @@ func (s *requestpromptsSuite) TestHandleNewRuleAllowPermissions(c *C) { pathPattern, err := patterns.ParsePathPattern("/home/test/Documents/**") c.Assert(err, IsNil) - permissions := []string{"read", "write", "append"} - constraints := &prompting.Constraints{ + constraints := &prompting.RuleConstraints{ PathPattern: pathPattern, - Permissions: permissions, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{Outcome: prompting.OutcomeAllow}, + "execute": &prompting.RulePermissionEntry{Outcome: prompting.OutcomeDeny}, + "append": &prompting.RulePermissionEntry{Outcome: prompting.OutcomeAllow}, + }, } - outcome := prompting.OutcomeAllow - satisfied, err := pdb.HandleNewRule(metadata, constraints, outcome) + satisfied, err := pdb.HandleNewRule(metadata, constraints) c.Assert(err, IsNil) c.Check(satisfied, HasLen, 2) - c.Check(promptIDListContains(satisfied, prompt2.ID), Equals, true) + c.Check(promptIDListContains(satisfied, prompt1.ID), Equals, true) c.Check(promptIDListContains(satisfied, prompt3.ID), Equals, true) - // Read and write permissions of prompt1 satisfied, so notice re-issued, - // but it has one remaining permission. prompt2 and prompt3 fully satisfied. - e1 := ¬iceInfo{promptID: prompt1.ID, data: nil} - e2 := ¬iceInfo{promptID: prompt2.ID, data: map[string]string{"resolved": "satisfied"}} + // Read permissions of prompt2 satisfied, but it has one remaining + // permission, so notice re-issued. prompt1 satisfied because at least + // one permission was denied, and prompt3 permissions fully satisfied. + e1 := ¬iceInfo{promptID: prompt1.ID, data: map[string]string{"resolved": "satisfied"}} + e2 := ¬iceInfo{promptID: prompt2.ID, data: nil} e3 := ¬iceInfo{promptID: prompt3.ID, data: map[string]string{"resolved": "satisfied"}} expectedNotices := []*noticeInfo{e1, e2, e3} s.checkNewNoticesUnordered(c, expectedNotices) @@ -691,15 +707,18 @@ func (s *requestpromptsSuite) TestHandleNewRuleAllowPermissions(c *C) { for i := 0; i < 2; i++ { satisfiedReq, allowedPermission, err := s.waitForListenerReqAndReply(c, listenerReqChan, replyChan) c.Check(err, IsNil) - var perms []string switch satisfiedReq { - case listenerReq2: - perms = permissions2 - case listenerReq3: - perms = permissions3 + case listenerReq1, listenerReq3: + break default: c.Errorf("unexpected request satisfied by new rule") } + // Only "read" permission was allowed for either prompt. + // prompt1 had requested "write" and "execute" as well, but because + // "execute" was denied and there was no rule pertaining to "write", + // the latter were both denied, leaving "read" as the only permission + // allowed. + perms := []string{"read"} expectedPerm, err := prompting.AbstractPermissionsToAppArmorPermissions(metadata.Interface, perms) c.Check(err, IsNil) c.Check(allowedPermission, DeepEquals, expectedPerm) @@ -709,25 +728,27 @@ func (s *requestpromptsSuite) TestHandleNewRuleAllowPermissions(c *C) { c.Assert(err, IsNil) c.Assert(stored, HasLen, 2) - // Check that allowing the final missing permission allows the prompt. - permissions = []string{"execute"} - constraints = &prompting.Constraints{ + // Check that allowing the final missing permission of prompt2 satisfies it + // with an allow response. + constraints = &prompting.RuleConstraints{ PathPattern: pathPattern, - Permissions: permissions, + Permissions: prompting.RulePermissionMap{ + "write": &prompting.RulePermissionEntry{Outcome: prompting.OutcomeAllow}, + }, } - satisfied, err = pdb.HandleNewRule(metadata, constraints, outcome) + satisfied, err = pdb.HandleNewRule(metadata, constraints) c.Assert(err, IsNil) c.Check(satisfied, HasLen, 1) - c.Check(satisfied[0], Equals, prompt1.ID) + c.Check(satisfied[0], Equals, prompt2.ID) expectedData := map[string]string{"resolved": "satisfied"} - s.checkNewNoticesSimple(c, []prompting.IDType{prompt1.ID}, expectedData) + s.checkNewNoticesSimple(c, []prompting.IDType{prompt2.ID}, expectedData) satisfiedReq, allowedPermission, err := s.waitForListenerReqAndReply(c, listenerReqChan, replyChan) c.Check(err, IsNil) - c.Check(satisfiedReq, Equals, listenerReq1) - expectedPerm, err := prompting.AbstractPermissionsToAppArmorPermissions(metadata.Interface, permissions1) + c.Check(satisfiedReq, Equals, listenerReq2) + expectedPerm, err := prompting.AbstractPermissionsToAppArmorPermissions(metadata.Interface, permissions2) c.Check(err, IsNil) c.Check(allowedPermission, DeepEquals, expectedPerm) } @@ -741,95 +762,6 @@ func promptIDListContains(haystack []prompting.IDType, needle prompting.IDType) return false } -func (s *requestpromptsSuite) TestHandleNewRuleDenyPermissions(c *C) { - listenerReqChan := make(chan *listener.Request, 3) - replyChan := make(chan any, 3) - restore := requestprompts.MockSendReply(func(listenerReq *listener.Request, allowedPermission any) error { - listenerReqChan <- listenerReq - replyChan <- allowedPermission - return nil - }) - defer restore() - - pdb, err := requestprompts.New(s.defaultNotifyPrompt) - c.Assert(err, IsNil) - defer pdb.Close() - - metadata := &prompting.Metadata{ - User: s.defaultUser, - Snap: "nextcloud", - Interface: "home", - } - path := "/home/test/Documents/foo.txt" - - permissions1 := []string{"read", "write", "execute"} - listenerReq1 := &listener.Request{} - prompt1, merged, err := pdb.AddOrMerge(metadata, path, permissions1, permissions1, listenerReq1) - c.Assert(err, IsNil) - c.Check(merged, Equals, false) - - permissions2 := []string{"read", "write"} - listenerReq2 := &listener.Request{} - prompt2, merged, err := pdb.AddOrMerge(metadata, path, permissions2, permissions2, listenerReq2) - c.Assert(err, IsNil) - c.Check(merged, Equals, false) - - permissions3 := []string{"read"} - listenerReq3 := &listener.Request{} - prompt3, merged, err := pdb.AddOrMerge(metadata, path, permissions3, permissions3, listenerReq3) - c.Assert(err, IsNil) - c.Check(merged, Equals, false) - - permissions4 := []string{"open"} - listenerReq4 := &listener.Request{} - prompt4, merged, err := pdb.AddOrMerge(metadata, path, permissions4, permissions4, listenerReq4) - c.Assert(err, IsNil) - c.Check(merged, Equals, false) - - s.checkNewNoticesSimple(c, []prompting.IDType{prompt1.ID, prompt2.ID, prompt3.ID, prompt4.ID}, nil) - - clientActivity := false // doesn't matter if it's true or false for this test - stored, err := pdb.Prompts(metadata.User, clientActivity) - c.Assert(err, IsNil) - c.Assert(stored, HasLen, 4) - - pathPattern, err := patterns.ParsePathPattern("/home/test/Documents/**") - c.Assert(err, IsNil) - permissions := []string{"read"} - constraints := &prompting.Constraints{ - PathPattern: pathPattern, - Permissions: permissions, - } - outcome := prompting.OutcomeDeny - - // If one or more permissions denied each for prompts 1-3, so each is denied - satisfied, err := pdb.HandleNewRule(metadata, constraints, outcome) - c.Assert(err, IsNil) - c.Check(satisfied, HasLen, 3) - c.Check(promptIDListContains(satisfied, prompt1.ID), Equals, true) - c.Check(promptIDListContains(satisfied, prompt2.ID), Equals, true) - c.Check(promptIDListContains(satisfied, prompt3.ID), Equals, true) - - expectedData := map[string]string{"resolved": "satisfied"} - s.checkNewNoticesUnorderedSimple(c, []prompting.IDType{prompt1.ID, prompt2.ID, prompt3.ID}, expectedData) - - for i := 0; i < 3; i++ { - satisfiedReq, allowedPermission, err := s.waitForListenerReqAndReply(c, listenerReqChan, replyChan) - c.Check(err, IsNil) - switch satisfiedReq { - case listenerReq1, listenerReq2, listenerReq3: - break - default: - c.Errorf("unexpected request satisfied by new rule") - } - c.Check(allowedPermission, DeepEquals, notify.FilePermission(0)) - } - - stored, err = pdb.Prompts(metadata.User, clientActivity) - c.Check(err, IsNil) - c.Check(stored, HasLen, 1) -} - func (s *requestpromptsSuite) TestHandleNewRuleNonMatches(c *C) { listenerReqChan := make(chan *listener.Request, 1) replyChan := make(chan any, 1) @@ -863,22 +795,31 @@ func (s *requestpromptsSuite) TestHandleNewRuleNonMatches(c *C) { pathPattern, err := patterns.ParsePathPattern("/home/test/Documents/**") c.Assert(err, IsNil) - constraints := &prompting.Constraints{ + constraints := &prompting.RuleConstraints{ PathPattern: pathPattern, - Permissions: permissions, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{Outcome: prompting.OutcomeAllow}, + }, + } + + badOutcomeConstraints := &prompting.RuleConstraints{ + PathPattern: pathPattern, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{Outcome: prompting.OutcomeType("foo")}, + }, } - outcome := prompting.OutcomeAllow otherUser := user + 1 otherSnap := "ldx" otherInterface := "system-files" otherPattern, err := patterns.ParsePathPattern("/home/test/Pictures/**.png") c.Assert(err, IsNil) - otherConstraints := &prompting.Constraints{ + otherConstraints := &prompting.RuleConstraints{ PathPattern: otherPattern, - Permissions: permissions, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{Outcome: prompting.OutcomeAllow}, + }, } - badOutcome := prompting.OutcomeType("foo") clientActivity := false // doesn't matter if it's true or false for this test stored, err := pdb.Prompts(metadata.User, clientActivity) @@ -886,7 +827,7 @@ func (s *requestpromptsSuite) TestHandleNewRuleNonMatches(c *C) { c.Assert(stored, HasLen, 1) c.Assert(stored[0], Equals, prompt) - satisfied, err := pdb.HandleNewRule(metadata, constraints, badOutcome) + satisfied, err := pdb.HandleNewRule(metadata, badOutcomeConstraints) c.Check(err, ErrorMatches, `invalid outcome: "foo"`) c.Check(satisfied, IsNil) @@ -897,7 +838,7 @@ func (s *requestpromptsSuite) TestHandleNewRuleNonMatches(c *C) { Snap: snap, Interface: iface, } - satisfied, err = pdb.HandleNewRule(otherUserMetadata, constraints, outcome) + satisfied, err = pdb.HandleNewRule(otherUserMetadata, constraints) c.Check(err, IsNil) c.Check(satisfied, IsNil) @@ -908,7 +849,7 @@ func (s *requestpromptsSuite) TestHandleNewRuleNonMatches(c *C) { Snap: otherSnap, Interface: iface, } - satisfied, err = pdb.HandleNewRule(otherSnapMetadata, constraints, outcome) + satisfied, err = pdb.HandleNewRule(otherSnapMetadata, constraints) c.Check(err, IsNil) c.Check(satisfied, IsNil) @@ -919,19 +860,19 @@ func (s *requestpromptsSuite) TestHandleNewRuleNonMatches(c *C) { Snap: snap, Interface: otherInterface, } - satisfied, err = pdb.HandleNewRule(otherInterfaceMetadata, constraints, outcome) + satisfied, err = pdb.HandleNewRule(otherInterfaceMetadata, constraints) c.Check(err, IsNil) c.Check(satisfied, IsNil) s.checkNewNoticesSimple(c, []prompting.IDType{}, nil) - satisfied, err = pdb.HandleNewRule(metadata, otherConstraints, outcome) + satisfied, err = pdb.HandleNewRule(metadata, otherConstraints) c.Check(err, IsNil) c.Check(satisfied, IsNil) s.checkNewNoticesSimple(c, []prompting.IDType{}, nil) - satisfied, err = pdb.HandleNewRule(metadata, constraints, outcome) + satisfied, err = pdb.HandleNewRule(metadata, constraints) c.Check(err, IsNil) c.Assert(satisfied, HasLen, 1) @@ -1061,7 +1002,7 @@ func (s *requestpromptsSuite) TestCloseThenOperate(c *C) { c.Check(err, Equals, prompting_errors.ErrPromptsClosed) c.Check(result, IsNil) - promptIDs, err := pdb.HandleNewRule(nil, nil, prompting.OutcomeDeny) + promptIDs, err := pdb.HandleNewRule(nil, nil) c.Check(err, Equals, prompting_errors.ErrPromptsClosed) c.Check(promptIDs, IsNil) diff --git a/interfaces/prompting/requestrules/export_test.go b/interfaces/prompting/requestrules/export_test.go index 04523754cdd..1f27b0803ae 100644 --- a/interfaces/prompting/requestrules/export_test.go +++ b/interfaces/prompting/requestrules/export_test.go @@ -27,6 +27,6 @@ var JoinInternalErrors = joinInternalErrors type RulesDBJSON rulesDBJSON -func (rule *Rule) Validate(currTime time.Time) error { +func (rule *Rule) Validate(currTime time.Time) (expired bool, err error) { return rule.validate(currTime) } diff --git a/interfaces/prompting/requestrules/requestrules.go b/interfaces/prompting/requestrules/requestrules.go index 2504198b07d..3caa1087063 100644 --- a/interfaces/prompting/requestrules/requestrules.go +++ b/interfaces/prompting/requestrules/requestrules.go @@ -37,68 +37,44 @@ import ( "github.com/snapcore/snapd/interfaces/prompting/patterns" "github.com/snapcore/snapd/logger" "github.com/snapcore/snapd/osutil" + "github.com/snapcore/snapd/strutil" ) // Rule stores the contents of a request rule. type Rule struct { - ID prompting.IDType `json:"id"` - Timestamp time.Time `json:"timestamp"` - User uint32 `json:"user"` - Snap string `json:"snap"` - Interface string `json:"interface"` - Constraints *prompting.Constraints `json:"constraints"` - Outcome prompting.OutcomeType `json:"outcome"` - Lifespan prompting.LifespanType `json:"lifespan"` - Expiration time.Time `json:"expiration,omitempty"` + ID prompting.IDType `json:"id"` + Timestamp time.Time `json:"timestamp"` + User uint32 `json:"user"` + Snap string `json:"snap"` + Interface string `json:"interface"` + Constraints *prompting.RuleConstraints `json:"constraints"` } -// Validate verifies internal correctness of the rule -func (rule *Rule) validate(currTime time.Time) error { - if err := rule.Constraints.ValidateForInterface(rule.Interface); err != nil { - return err - } - if _, err := rule.Outcome.AsBool(); err != nil { - return err - } - if rule.Lifespan == prompting.LifespanSingle { - // We don't allow rules with lifespan "single" - return prompting_errors.NewRuleLifespanSingleError(prompting.SupportedRuleLifespans) - } - if err := rule.Lifespan.ValidateExpiration(rule.Expiration, currTime); err != nil { - // Should never error due to an API request, since rules are always - // added via the API using duration, rather than expiration. - // Error may occur when validating a rule loaded from disk. - return err - } - return nil +// Validate verifies internal correctness of the rule's constraints and +// permissions and prunes any expired permissions. If all permissions are +// expired, then returns true. If the rule is invalid, returns an error. +func (rule *Rule) validate(currTime time.Time) (expired bool, err error) { + return rule.Constraints.ValidateForInterface(rule.Interface, currTime) } -// Expired returns true if the receiving rule has a lifespan of timespan and -// the current time is after the rule's expiration time. -func (rule *Rule) Expired(currentTime time.Time) bool { - switch rule.Lifespan { - case prompting.LifespanTimespan: - if currentTime.After(rule.Expiration) { - return true - } - // TODO: add lifespan session - //case prompting.LifespanSession: - // TODO: return true if the user session has changed - } - return false +// expired returns true if all permissions for the receiving rule have expired. +func (rule *Rule) expired(currTime time.Time) bool { + return rule.Constraints.Permissions.Expired(currTime) } // variantEntry stores the actual pattern variant struct which can be used to -// match paths, and the set of rule IDs whose path patterns render to this -// variant. All rules in a particular entry must have the same outcome, and -// that outcome is stored directly in the variant entry itself. +// match paths, and a map from rule IDs whose path patterns render to this +// variant to the relevant permission entry from that rule. All non-expired +// permission entry values in the map must have the same outcome (as long as +// the entry has not expired), and that outcome is also stored directly in the +// variant entry itself. // // Use the rendered string as the key for this entry, since pattern variants // cannot otherwise be easily checked for equality. type variantEntry struct { - Variant patterns.PatternVariant - Outcome prompting.OutcomeType - RuleIDs map[prompting.IDType]bool + Variant patterns.PatternVariant + Outcome prompting.OutcomeType + RuleEntries map[prompting.IDType]*prompting.RulePermissionEntry } // permissionDB stores a map from path pattern variant to the ID of the rule @@ -140,10 +116,9 @@ type RuleDB struct { indexByID map[prompting.IDType]int rules []*Rule - // the incoming request queries are made in the context of a user, snap, - // snap interface, path, so this is essential a secondary compound index - // made of all those properties for being able to identify a rule - // matching given query + // Rules are stored in a tree according to user, snap, interface, and + // permission to simplify the process of checking whether a given request + // is matched by existing rules, and which of those rules has precedence. perUser map[uint32]*userDB dbPath string @@ -208,7 +183,7 @@ func (rdb *RuleDB) load() (retErr error) { rdb.rules = make([]*Rule, 0) rdb.perUser = make(map[uint32]*userDB) - expiredRules := make(map[*Rule]bool) + expiredRules := make(map[prompting.IDType]bool) f, err := os.Open(rdb.dbPath) if err != nil { @@ -227,27 +202,26 @@ func (rdb *RuleDB) load() (retErr error) { loadErr := fmt.Errorf("cannot read stored request rules: %w", err) // Save the empty rule DB to disk to overwrite the previous one which // could not be decoded. - return errorsJoin(loadErr, rdb.save()) + return strutil.JoinErrors(loadErr, rdb.save()) } currTime := time.Now() var errInvalid error for _, rule := range wrapped.Rules { - if rule.Expired(currTime) { - expiredRules[rule] = true - continue - } - // If an expired rule happens to be invalid, it's fine, since we remove - // it anyway. - - if err := rule.validate(currTime); err != nil { + expired, err := rule.validate(currTime) + if err != nil { // we're loading previously saved rules, so this should not happen errInvalid = fmt.Errorf("internal error: %w", err) break } + if expired { + expiredRules[rule.ID] = true + continue + } - if conflictErr := rdb.addRule(rule); conflictErr != nil { + conflictErr := rdb.addRule(rule) + if conflictErr != nil { // Duplicate rules on disk or conflicting rule, should not occur errInvalid = fmt.Errorf("cannot add rule: %w", conflictErr) break @@ -266,13 +240,13 @@ func (rdb *RuleDB) load() (retErr error) { // Save the empty rule DB to disk to overwrite the previous one which // was invalid. - return errorsJoin(errInvalid, rdb.save()) + return strutil.JoinErrors(errInvalid, rdb.save()) } expiredData := map[string]string{"removed": "expired"} for _, rule := range wrapped.Rules { var data map[string]string - if expiredRules[rule] { + if expiredRules[rule.ID] { data = expiredData } rdb.notifyRule(rule.User, rule.ID, data) @@ -306,6 +280,8 @@ func (rdb *RuleDB) lookupRuleByID(id prompting.IDType) (*Rule, error) { if !exists { return nil, prompting_errors.ErrRuleNotFound } + // XXX: should we check whether a rule is expired and throw ErrRuleNotFound + // if so? if index >= len(rdb.rules) { // Internal inconsistency between rules list and IDs map, should not occur return nil, prompting_errors.ErrRuleDBInconsistent @@ -331,22 +307,24 @@ func (rdb *RuleDB) addRuleToRulesList(rule *Rule) error { return nil } -// addRule adds the given rule to the rule DB. If there is a conflicting rule, -// returns an error, and the rule DB is left unchanged. +// addRule adds the given rule to the rule DB. +// +// If there is a conflicting rule, returns an error, and the rule DB is left +// unchanged. // // The caller must ensure that the database lock is held for writing. func (rdb *RuleDB) addRule(rule *Rule) error { if err := rdb.addRuleToRulesList(rule); err != nil { return err } - err := rdb.addRuleToTree(rule) - if err == nil { + conflictErr := rdb.addRuleToTree(rule) + if conflictErr == nil { return nil } // remove just-added rule from rules list and IDs rdb.rules = rdb.rules[:len(rdb.rules)-1] delete(rdb.indexByID, rule.ID) - return err + return conflictErr } // removeRuleByIDFromRulesList removes the rule with the given ID from the rules @@ -407,13 +385,16 @@ func (rdb *RuleDB) removeRuleByID(id prompting.IDType) (*Rule, error) { // If there are other rules which have a conflicting path pattern and // permission, returns an error with information about the conflicting rules. // +// Assumes that the rule has already been internally validated. No additional +// validation is done in this function, nor is the expiration of the permissions +// checked. +// // The caller must ensure that the database lock is held for writing. func (rdb *RuleDB) addRuleToTree(rule *Rule) *prompting_errors.RuleConflictError { addedPermissions := make([]string, 0, len(rule.Constraints.Permissions)) - var conflicts []prompting_errors.RuleConflict - for _, permission := range rule.Constraints.Permissions { - permConflicts := rdb.addRulePermissionToTree(rule, permission) + for permission, entry := range rule.Constraints.Permissions { + permConflicts := rdb.addRulePermissionToTree(rule, permission, entry) if len(permConflicts) > 0 { conflicts = append(conflicts, permConflicts...) continue @@ -448,18 +429,21 @@ func (rdb *RuleDB) addRuleToTree(rule *Rule) *prompting_errors.RuleConflictError // call are removed from the variant map, leaving it unchanged, and the list of // conflicts is returned. If there are no conflicts, returns nil. // -// Expired rules, whether their outcome conflicts with the new rule or not, are -// ignored and never treated as conflicts. If there are no conflicts with non- -// expired rules, then all expired rules are removed. If there is a conflict -// with a non-expired rule, then nothing about the rule DB state is changed, -// including expired rules. +// Rules which are expired according to the timestamp of the rule being added, +// whether their outcome conflicts with the new rule or not, are ignored and +// never treated as conflicts. If there are no conflicts with non-expired +// rules, then all expired rules are removed from the tree entry (though not +// removed from the rule DB as a whole, nor is a notice recorded). If there is +// a conflict with a non-expired rule, then nothing about the rule DB state is +// changed, including expired rules. // -// The caller must ensure that the database lock is held for writing. -func (rdb *RuleDB) addRulePermissionToTree(rule *Rule, permission string) []prompting_errors.RuleConflict { +// The caller must ensure that the database lock is held for writing, and that +// the given entry is not expired. +func (rdb *RuleDB) addRulePermissionToTree(rule *Rule, permission string, permissionEntry *prompting.RulePermissionEntry) []prompting_errors.RuleConflict { permVariants := rdb.ensurePermissionDBForUserSnapInterfacePermission(rule.User, rule.Snap, rule.Interface, permission) newVariantEntries := make(map[string]variantEntry, rule.Constraints.PathPattern.NumVariants()) - expiredRules := make(map[prompting.IDType]bool) + partiallyExpiredRules := make(map[prompting.IDType]bool) var conflicts []prompting_errors.RuleConflict addVariant := func(index int, variant patterns.PatternVariant) { @@ -467,28 +451,28 @@ func (rdb *RuleDB) addRulePermissionToTree(rule *Rule, permission string) []prom existingEntry, exists := permVariants.VariantEntries[variantStr] if !exists { newVariantEntries[variantStr] = variantEntry{ - Variant: variant, - Outcome: rule.Outcome, - RuleIDs: map[prompting.IDType]bool{rule.ID: true}, + Variant: variant, + Outcome: permissionEntry.Outcome, + RuleEntries: map[prompting.IDType]*prompting.RulePermissionEntry{rule.ID: permissionEntry}, } return } - newEntry := variantEntry{ - Variant: variant, - Outcome: rule.Outcome, - RuleIDs: make(map[prompting.IDType]bool, len(existingEntry.RuleIDs)+1), + newVariantEntry := variantEntry{ + Variant: variant, + Outcome: permissionEntry.Outcome, + RuleEntries: make(map[prompting.IDType]*prompting.RulePermissionEntry, len(existingEntry.RuleEntries)+1), } - newEntry.RuleIDs[rule.ID] = true - newVariantEntries[variantStr] = newEntry - for id := range existingEntry.RuleIDs { - if rdb.isRuleWithIDExpired(id, rule.Timestamp) { + newVariantEntry.RuleEntries[rule.ID] = permissionEntry + newVariantEntries[variantStr] = newVariantEntry + for id, entry := range existingEntry.RuleEntries { + if entry.Expired(rule.Timestamp) { // Don't preserve expired rules, and don't care if they conflict - expiredRules[id] = true + partiallyExpiredRules[id] = true continue } - if existingEntry.Outcome == rule.Outcome { + if existingEntry.Outcome == permissionEntry.Outcome { // Preserve non-expired rule which doesn't conflict - newEntry.RuleIDs[id] = true + newVariantEntry.RuleEntries[id] = entry continue } // Conflicting non-expired rule @@ -507,37 +491,42 @@ func (rdb *RuleDB) addRulePermissionToTree(rule *Rule, permission string) []prom return conflicts } - for ruleID := range expiredRules { - removedRule, err := rdb.removeRuleByID(ruleID) + expiredData := map[string]string{"removed": "expired"} + for ruleID := range partiallyExpiredRules { + maybeExpired, err := rdb.lookupRuleByIDForUser(rule.User, ruleID) + if err != nil { + // Error shouldn't occur. If it does, the rule was already removed + continue + } + if !maybeExpired.expired(rule.Timestamp) { + // Previously removed the rule's permission entry from the tree for + // this permission, now let's remove it from the rule as well. + delete(maybeExpired.Constraints.Permissions, permission) + + // This should not occur during load since it calls rule.validate() + // which calls RuleConstraints.ValidateForInterface, which prunes + // any expired permissions. Thus, it should only occur when adding + // a new rule which overlaps with another rule which has partially + // expired. + continue + } + _, err = rdb.removeRuleByID(ruleID) // Error shouldn't occur. If it does, the rule was already removed. if err == nil { - rdb.notifyRule(removedRule.User, removedRule.ID, - map[string]string{"removed": "expired"}) + rdb.notifyRule(maybeExpired.User, maybeExpired.ID, expiredData) } } - for variantStr, entry := range newVariantEntries { + for variantStr, variantEntry := range newVariantEntries { // Replace the old variant entries with the new ones. // This removes any expired rules from the entries, since these were // not preserved in the new variant entries. - permVariants.VariantEntries[variantStr] = entry + permVariants.VariantEntries[variantStr] = variantEntry } return nil } -// isRuleWithIDExpired returns true if the rule with given ID is expired with respect -// to the provided timestamp, or if it otherwise no longer exists. -// -// The caller must ensure that the database lock is held for writing. -func (rdb *RuleDB) isRuleWithIDExpired(id prompting.IDType, currTime time.Time) bool { - rule, err := rdb.lookupRuleByID(id) - if err != nil { - return true - } - return rule.Expired(currTime) -} - // removeRuleFromTree fully removes the given rule from the rules tree, even if // an error occurs. Whenever possible, it is preferred to use `removeRuleByID` // directly instead, since it ensures consistency between the rules list and the @@ -546,11 +535,11 @@ func (rdb *RuleDB) isRuleWithIDExpired(id prompting.IDType, currTime time.Time) // The caller must ensure that the database lock is held for writing. func (rdb *RuleDB) removeRuleFromTree(rule *Rule) error { var errs []error - for _, permission := range rule.Constraints.Permissions { - if es := rdb.removeRulePermissionFromTree(rule, permission); len(es) > 0 { + for permission := range rule.Constraints.Permissions { + if err := rdb.removeRulePermissionFromTree(rule, permission); err != nil { // Database was left inconsistent, should not occur. // Store the errors, but keep removing. - errs = append(errs, es...) + errs = append(errs, err) } } return joinInternalErrors(errs) @@ -565,14 +554,13 @@ func (rdb *RuleDB) removeRuleFromTree(rule *Rule) error { // errors which occurred. // // The caller must ensure that the database lock is held for writing. -func (rdb *RuleDB) removeRulePermissionFromTree(rule *Rule, permission string) []error { +func (rdb *RuleDB) removeRulePermissionFromTree(rule *Rule, permission string) error { permVariants, ok := rdb.permissionDBForUserSnapInterfacePermission(rule.User, rule.Snap, rule.Interface, permission) if !ok || permVariants == nil { err := fmt.Errorf("internal error: no rules in the rule tree for user %d, snap %q, interface %q, permission %q", rule.User, rule.Snap, rule.Interface, permission) - return []error{err} + return err } seenVariants := make(map[string]bool, rule.Constraints.PathPattern.NumVariants()) - var errs []error removeVariant := func(index int, variant patterns.PatternVariant) { variantStr := variant.String() if seenVariants[variantStr] { @@ -581,23 +569,25 @@ func (rdb *RuleDB) removeRulePermissionFromTree(rule *Rule, permission string) [ seenVariants[variantStr] = true variantEntry, exists := permVariants.VariantEntries[variantStr] if !exists { - // Database was left inconsistent, should not occur - errs = append(errs, fmt.Errorf(`internal error: path pattern variant not found in the rule tree: %q`, variant)) + // If doesn't exist, could have been removed due to another rule's + // variant being removed and, finding all other rules' permissions + // for this variant expired, removing the variant entry. + return } - delete(variantEntry.RuleIDs, rule.ID) - if len(variantEntry.RuleIDs) == 0 { + delete(variantEntry.RuleEntries, rule.ID) + if len(variantEntry.RuleEntries) == 0 { delete(permVariants.VariantEntries, variantStr) } } rule.Constraints.PathPattern.RenderAllVariants(removeVariant) - return errs + return nil } // joinInternalErrors wraps a prompting_errors.ErrRuleDBInconsistent with the given errors. // // If there are no non-nil errors in the given errs list, return nil. func joinInternalErrors(errs []error) error { - joinedErr := errorsJoin(errs...) + joinedErr := strutil.JoinErrors(errs...) if joinedErr == nil { return nil } @@ -605,28 +595,6 @@ func joinInternalErrors(errs []error) error { return fmt.Errorf("%w\n%v", prompting_errors.ErrRuleDBInconsistent, joinedErr) } -// errorsJoin returns an error that wraps the given errors. -// Any nil error values are discarded. -// errorsJoin returns nil if every value in errs is nil. -// -// TODO: replace with errors.Join() once we're on golang v1.20+ -func errorsJoin(errs ...error) error { - var nonNilErrs []error - for _, e := range errs { - if e != nil { - nonNilErrs = append(nonNilErrs, e) - } - } - if len(nonNilErrs) == 0 { - return nil - } - err := nonNilErrs[0] - for _, e := range nonNilErrs[1:] { - err = fmt.Errorf("%w\n%v", err, e) - } - return err -} - // permissionDBForUserSnapInterfacePermission returns the permission DB for the // given user, snap, interface, and permission, if it exists. // @@ -707,7 +675,7 @@ func (rdb *RuleDB) Close() error { // Creates a rule with the given information and adds it to the rule database. // If any of the given parameters are invalid, returns an error. Otherwise, // returns the newly-added rule, and saves the database to disk. -func (rdb *RuleDB) AddRule(user uint32, snap string, iface string, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string) (*Rule, error) { +func (rdb *RuleDB) AddRule(user uint32, snap string, iface string, constraints *prompting.Constraints) (*Rule, error) { rdb.mutex.Lock() defer rdb.mutex.Unlock() @@ -715,11 +683,14 @@ func (rdb *RuleDB) AddRule(user uint32, snap string, iface string, constraints * return nil, prompting_errors.ErrRulesClosed } - newRule, err := rdb.makeNewRule(user, snap, iface, constraints, outcome, lifespan, duration) + newRule, err := rdb.makeNewRule(user, snap, iface, constraints) if err != nil { return nil, err } if err := rdb.addRule(newRule); err != nil { + // Cannot have expired, since the expiration is based on the lifespan, + // duration, and timestamp, all of which were validated and set in + // makeNewRule. return nil, fmt.Errorf("cannot add rule: %w", err) } @@ -738,40 +709,29 @@ func (rdb *RuleDB) AddRule(user uint32, snap string, iface string, constraints * // makeNewRule creates a new Rule with the given contents. // -// Users of requestrules should probably autofill rules from JSON and never call -// this function directly. -// -// Constructs a new rule with the given parameters as values, with the -// exception of duration. Uses the given duration, in addition to the current -// time, to compute the expiration time for the rule, and stores that as part -// of the rule which is returned. If any of the given parameters are invalid, -// returns a corresponding error. -func (rdb *RuleDB) makeNewRule(user uint32, snap string, iface string, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string) (*Rule, error) { +// Constructs a new rule with the given parameters as values. The given +// constraints are converted to rule constraints, using the timestamp of the +// new rule as the baseline with which to compute an expiration from any given +// duration. If any of the given parameters are invalid, returns an error. +func (rdb *RuleDB) makeNewRule(user uint32, snap string, iface string, constraints *prompting.Constraints) (*Rule, error) { currTime := time.Now() - expiration, err := lifespan.ParseDuration(duration, currTime) + ruleConstraints, err := constraints.ToRuleConstraints(iface, currTime) if err != nil { return nil, err } + // Don't consume an ID until now, when we know the rule is valid + id, _ := rdb.maxIDMmap.NextID() + newRule := Rule{ + ID: id, Timestamp: currTime, User: user, Snap: snap, Interface: iface, - Constraints: constraints, - Outcome: outcome, - Lifespan: lifespan, - Expiration: expiration, + Constraints: ruleConstraints, } - if err := newRule.validate(currTime); err != nil { - return nil, err - } - - // Don't consume an ID until now, when we know the rule is valid - id, _ := rdb.maxIDMmap.NextID() - newRule.ID = id - return &newRule, nil } @@ -792,8 +752,8 @@ func (rdb *RuleDB) IsPathAllowed(user uint32, snap string, iface string, path st currTime := time.Now() for variantStr, variantEntry := range variantMap { nonExpired := false - for id := range variantEntry.RuleIDs { - if !rdb.isRuleWithIDExpired(id, currTime) { + for _, rulePermissionEntry := range variantEntry.RuleEntries { + if !rulePermissionEntry.Expired(currTime) { nonExpired = true break } @@ -857,7 +817,11 @@ func (rdb *RuleDB) rulesInternal(ruleFilter func(rule *Rule) bool) []*Rule { rules := make([]*Rule, 0) currTime := time.Now() for _, rule := range rdb.rules { - if rule.Expired(currTime) { + if rule.expired(currTime) { + // XXX: it would be nice if we pruned expired permissions from a + // rule before including it in the rules list, if it's not expired. + // Since we don't hold the write lock, we don't want to + // automatically prune expired permissions here. Should this change? continue } @@ -1038,16 +1002,29 @@ func (rdb *RuleDB) RemoveRulesForSnapInterface(user uint32, snap string, iface s return rules, nil } -// PatchRule modifies the rule with the given ID by updating the rule's fields -// corresponding to any of the given parameters which are set/non-empty. +// PatchRule modifies the rule with the given ID by updating the rule's +// constraints for any patch field or permission which is set/non-empty. +// +// If the path pattern is nil in the patch, it is left unchanged from the +// existing rule. Any permissions which are omitted from the permissions map +// in the patch are left unchanged from the existing rule. To remove an +// existing permission from the rule, the permission in the patch should map +// to nil. // -// Any of the parameters which are equal to the default/unset value for their -// types are left unchanged from the existing rule. Even if the given new rule -// contents exactly match the existing rule contents, the timestamp of the rule -// is updated to the current time. If there is any error while modifying the -// rule, the rule is rolled back to its previous unmodified state, leaving the -// database unchanged. If the database is changed, it is saved to disk. -func (rdb *RuleDB) PatchRule(user uint32, id prompting.IDType, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string) (r *Rule, err error) { +// Permission entries must be provided as complete units, containing both +// outcome and lifespan (and duration, if lifespan is timespan). Since neither +// outcome nor lifespan are omitempty, the unmarshaller enforces this for us. +// +// Even if the given patch contents exactly match the existing rule contents, +// the timestamp of the rule is updated to the current time. If there is any +// error while modifying the rule, the rule is rolled back to its previous +// unmodified state, leaving the database unchanged. If the database is changed, +// it is saved to disk. +// +// XXX: Is there a client use-case for this API method? +// Clients can always delete a rule and re-add it later, which is basically what +// this method already does. +func (rdb *RuleDB) PatchRule(user uint32, id prompting.IDType, constraintsPatch *prompting.RuleConstraintsPatch) (r *Rule, err error) { rdb.mutex.Lock() defer rdb.mutex.Unlock() @@ -1059,21 +1036,31 @@ func (rdb *RuleDB) PatchRule(user uint32, id prompting.IDType, constraints *prom if err != nil { return nil, err } - if constraints == nil { - constraints = origRule.Constraints - } - if outcome == prompting.OutcomeUnset { - outcome = origRule.Outcome - } - if lifespan == prompting.LifespanUnset { - lifespan = origRule.Lifespan - } - newRule, err := rdb.makeNewRule(user, origRule.Snap, origRule.Interface, constraints, outcome, lifespan, duration) + // XXX: we don't currently check whether the rule is expired or not. + // Do we want to support patching a rule for which all the permissions + // have already expired? Or say if a rule has already expired, we don't + // support patching it? Currently, we don't include fully expired rules + // in the output of Rules(), should the same be done here? + + currTime := time.Now() + + if constraintsPatch == nil { + constraintsPatch = &prompting.RuleConstraintsPatch{} + } + ruleConstraints, err := constraintsPatch.PatchRuleConstraints(origRule.Constraints, origRule.Interface, currTime) if err != nil { return nil, err } - newRule.ID = origRule.ID + + newRule := &Rule{ + ID: origRule.ID, + Timestamp: currTime, + User: origRule.User, + Snap: origRule.Snap, + Interface: origRule.Interface, + Constraints: ruleConstraints, + } // Remove the existing rule from the tree. An error should not occur, since // we just looked up the rule and know it exists. @@ -1084,7 +1071,7 @@ func (rdb *RuleDB) PatchRule(user uint32, id prompting.IDType, constraints *prom // Try to re-add original rule so all is unchanged. if origErr := rdb.addRule(origRule); origErr != nil { // Error should not occur, but if it does, wrap it in the other error - err = errorsJoin(err, fmt.Errorf("cannot re-add original rule: %w", origErr)) + err = strutil.JoinErrors(err, fmt.Errorf("cannot re-add original rule: %w", origErr)) } return nil, err } diff --git a/interfaces/prompting/requestrules/requestrules_test.go b/interfaces/prompting/requestrules/requestrules_test.go index eb3d1f477f5..1dd589ce74d 100644 --- a/interfaces/prompting/requestrules/requestrules_test.go +++ b/interfaces/prompting/requestrules/requestrules_test.go @@ -25,6 +25,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "testing" "time" @@ -79,73 +80,12 @@ func (s *requestrulesSuite) SetUpTest(c *C) { c.Assert(os.MkdirAll(dirs.SnapdStateDir(dirs.GlobalRootDir), 0700), IsNil) } -func (s *requestrulesSuite) TestRuleValidate(c *C) { - iface := "home" - pathPattern := mustParsePathPattern(c, "/home/test/**") - - validConstraints := &prompting.Constraints{ - PathPattern: pathPattern, - Permissions: []string{"read"}, - } - invalidConstraints := &prompting.Constraints{ - PathPattern: pathPattern, - Permissions: []string{"foo"}, - } - - validOutcome := prompting.OutcomeAllow - invalidOutcome := prompting.OutcomeUnset - - validLifespan := prompting.LifespanTimespan - invalidLifespan := prompting.LifespanSingle - - currTime := time.Now() - - validExpiration := currTime.Add(time.Millisecond) - invalidExpiration := currTime.Add(-time.Millisecond) - - rule := requestrules.Rule{ - // ID is not validated - // Timestamp is not validated - // User is not validated - // Snap is not validated - Interface: iface, - Constraints: validConstraints, - Outcome: validOutcome, - Lifespan: validLifespan, - Expiration: validExpiration, - } - c.Check(rule.Validate(currTime), IsNil) - - rule.Expiration = invalidExpiration - c.Check(rule.Validate(currTime), ErrorMatches, fmt.Sprintf("%v:.*", prompting_errors.ErrRuleExpirationInThePast)) - - rule.Lifespan = invalidLifespan - c.Check(rule.Validate(currTime), ErrorMatches, prompting_errors.NewRuleLifespanSingleError(prompting.SupportedRuleLifespans).Error()) - - rule.Outcome = invalidOutcome - c.Check(rule.Validate(currTime), ErrorMatches, `invalid outcome: ""`) - - rule.Constraints = invalidConstraints - c.Check(rule.Validate(currTime), ErrorMatches, "invalid permissions for home interface:.*") -} - func mustParsePathPattern(c *C, patternStr string) *patterns.PathPattern { pattern, err := patterns.ParsePathPattern(patternStr) c.Assert(err, IsNil) return pattern } -func (s *requestrulesSuite) TestRuleExpired(c *C) { - currTime := time.Now() - rule := requestrules.Rule{ - // Other fields are not relevant - Lifespan: prompting.LifespanTimespan, - Expiration: currTime, - } - c.Check(rule.Expired(currTime), Equals, false) - c.Check(rule.Expired(currTime.Add(time.Millisecond)), Equals, true) -} - func (s *requestrulesSuite) TestNew(c *C) { rdb, err := requestrules.New(s.defaultNotifyRule) c.Assert(err, IsNil) @@ -237,6 +177,19 @@ func (s *requestrulesSuite) checkNewNotices(c *C, expectedNotices []*noticeInfo) s.ruleNotices = s.ruleNotices[:0] } +func (s *requestrulesSuite) checkNewNoticesUnordered(c *C, expectedNotices []*noticeInfo) { + sort.Slice(sortSliceParams(s.ruleNotices)) + sort.Slice(sortSliceParams(expectedNotices)) + s.checkNewNotices(c, expectedNotices) +} + +func sortSliceParams(list []*noticeInfo) ([]*noticeInfo, func(i, j int) bool) { + less := func(i, j int) bool { + return list[i].ruleID < list[j].ruleID + } + return list, less +} + func (s *requestrulesSuite) TestLoadErrorOpen(c *C) { dbPath := s.prepDBPath(c) // Create unreadable DB file to cause open failure @@ -257,10 +210,11 @@ func (s *requestrulesSuite) TestLoadErrorUnmarshal(c *C) { func (s *requestrulesSuite) TestLoadErrorValidate(c *C) { dbPath := s.prepDBPath(c) - good1 := s.ruleTemplate(c, prompting.IDType(1)) - bad := s.ruleTemplate(c, prompting.IDType(2)) + good1 := s.ruleTemplateWithRead(c, prompting.IDType(1)) + bad := s.ruleTemplateWithRead(c, prompting.IDType(2)) bad.Interface = "foo" // will cause validate() to fail with invalid constraints - good2 := s.ruleTemplate(c, prompting.IDType(3)) + good2 := s.ruleTemplateWithRead(c, prompting.IDType(3)) + good2.Constraints.Permissions["read"].Outcome = prompting.OutcomeDeny // Doesn't matter that rules have conflicting patterns/permissions, // validate() should catch invalid rule and exit before attempting to add. @@ -271,11 +225,22 @@ func (s *requestrulesSuite) TestLoadErrorValidate(c *C) { s.testLoadError(c, `internal error: invalid interface: "foo".*`, rules, checkWritten) } +// ruleTemplateWithRead returns a rule with valid contents, intended to be customized. +func (s *requestrulesSuite) ruleTemplateWithRead(c *C, id prompting.IDType) *requestrules.Rule { + rule := s.ruleTemplate(c, id) + rule.Constraints.Permissions["read"] = &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + // No expiration for lifespan forever + } + return rule +} + // ruleTemplate returns a rule with valid contents, intended to be customized. func (s *requestrulesSuite) ruleTemplate(c *C, id prompting.IDType) *requestrules.Rule { - constraints := prompting.Constraints{ + constraints := prompting.RuleConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/foo"), - Permissions: []string{"read"}, + Permissions: make(prompting.RulePermissionMap), } rule := requestrules.Rule{ ID: id, @@ -284,9 +249,6 @@ func (s *requestrulesSuite) ruleTemplate(c *C, id prompting.IDType) *requestrule Snap: "firefox", Interface: "home", Constraints: &constraints, - Outcome: prompting.OutcomeAllow, - Lifespan: prompting.LifespanForever, - // Skip Expiration } return &rule } @@ -304,12 +266,13 @@ func (s *requestrulesSuite) writeRules(c *C, dbPath string, rules []*requestrule func (s *requestrulesSuite) TestLoadErrorConflictingID(c *C) { dbPath := s.prepDBPath(c) currTime := time.Now() - good := s.ruleTemplate(c, prompting.IDType(1)) - // Expired rules should still get a {"removed": "dropped"} notice, even if they don't conflict + good := s.ruleTemplateWithRead(c, prompting.IDType(1)) + // Expired rules should still get a {"removed": "expired"} notice, even if they don't conflict expired := s.ruleTemplate(c, prompting.IDType(2)) - setPathPatternAndExpiration(c, expired, "/home/test/other", currTime.Add(-10*time.Second)) + expired.Constraints.PathPattern = mustParsePathPattern(c, "/home/test/other") + setPermissionsOutcomeLifespanExpiration(c, expired, []string{"read"}, prompting.OutcomeAllow, prompting.LifespanTimespan, currTime.Add(-10*time.Second)) // Add rule which conflicts with IDs but doesn't otherwise conflict - conflicting := s.ruleTemplate(c, prompting.IDType(1)) + conflicting := s.ruleTemplateWithRead(c, prompting.IDType(1)) conflicting.Constraints.PathPattern = mustParsePathPattern(c, "/home/test/another") rules := []*requestrules.Rule{good, expired, conflicting} @@ -319,24 +282,29 @@ func (s *requestrulesSuite) TestLoadErrorConflictingID(c *C) { s.testLoadError(c, fmt.Sprintf("cannot add rule: %v.*", prompting_errors.ErrRuleIDConflict), rules, checkWritten) } -func setPathPatternAndExpiration(c *C, rule *requestrules.Rule, pathPattern string, expiration time.Time) { - rule.Constraints.PathPattern = mustParsePathPattern(c, pathPattern) - rule.Lifespan = prompting.LifespanTimespan - rule.Expiration = expiration +func setPermissionsOutcomeLifespanExpiration(c *C, rule *requestrules.Rule, permissions []string, outcome prompting.OutcomeType, lifespan prompting.LifespanType, expiration time.Time) { + for _, perm := range permissions { + rule.Constraints.Permissions[perm] = &prompting.RulePermissionEntry{ + Outcome: outcome, + Lifespan: lifespan, + Expiration: expiration, + } + } } func (s *requestrulesSuite) TestLoadErrorConflictingPattern(c *C) { dbPath := s.prepDBPath(c) currTime := time.Now() - good := s.ruleTemplate(c, prompting.IDType(1)) - // Expired rules should still get a {"removed": "dropped"} notice, even if they don't conflict - expired := s.ruleTemplate(c, prompting.IDType(2)) - setPathPatternAndExpiration(c, expired, "/home/test/other", currTime.Add(-10*time.Second)) + good := s.ruleTemplateWithRead(c, prompting.IDType(1)) + // Expired rules should still get a {"removed": "expired"} notice, even if they don't conflict + expired := s.ruleTemplateWithRead(c, prompting.IDType(2)) + expired.Constraints.PathPattern = mustParsePathPattern(c, "/home/test/other") + setPermissionsOutcomeLifespanExpiration(c, expired, []string{"read"}, prompting.OutcomeAllow, prompting.LifespanTimespan, currTime.Add(-10*time.Second)) // Add rule with conflicting pattern and permissions but not conflicting ID. - conflicting := s.ruleTemplate(c, prompting.IDType(3)) + conflicting := s.ruleTemplateWithRead(c, prompting.IDType(3)) // Even with not all permissions being in conflict, still error - conflicting.Constraints.Permissions = []string{"read", "write"} - conflicting.Outcome = prompting.OutcomeDeny + var timeZero time.Time + setPermissionsOutcomeLifespanExpiration(c, conflicting, []string{"read", "write"}, prompting.OutcomeDeny, prompting.LifespanForever, timeZero) rules := []*requestrules.Rule{good, expired, conflicting} s.writeRules(c, dbPath, rules) @@ -349,23 +317,26 @@ func (s *requestrulesSuite) TestLoadExpiredRules(c *C) { dbPath := s.prepDBPath(c) currTime := time.Now() - good1 := s.ruleTemplate(c, prompting.IDType(1)) + good1 := s.ruleTemplateWithRead(c, prompting.IDType(1)) // At the moment, expired rules with conflicts are discarded without error, // but we don't want to test this as part of our contract expired1 := s.ruleTemplate(c, prompting.IDType(2)) - setPathPatternAndExpiration(c, expired1, "/home/test/other", currTime.Add(-10*time.Second)) + expired1.Constraints.PathPattern = mustParsePathPattern(c, "/home/test/other") + setPermissionsOutcomeLifespanExpiration(c, expired1, []string{"read"}, prompting.OutcomeAllow, prompting.LifespanTimespan, currTime.Add(-10*time.Second)) // Rules with same pattern but non-conflicting permissions do not conflict good2 := s.ruleTemplate(c, prompting.IDType(3)) - good2.Constraints.Permissions = []string{"write"} + var timeZero time.Time + setPermissionsOutcomeLifespanExpiration(c, good2, []string{"write"}, prompting.OutcomeDeny, prompting.LifespanForever, timeZero) expired2 := s.ruleTemplate(c, prompting.IDType(4)) - setPathPatternAndExpiration(c, expired2, "/home/test/another", currTime.Add(-time.Nanosecond)) + expired2.Constraints.PathPattern = mustParsePathPattern(c, "/home/test/another") + setPermissionsOutcomeLifespanExpiration(c, expired2, []string{"read"}, prompting.OutcomeAllow, prompting.LifespanTimespan, currTime.Add(-time.Nanosecond)) // Rules with different pattern and conflicting permissions do not conflict - good3 := s.ruleTemplate(c, prompting.IDType(5)) + good3 := s.ruleTemplateWithRead(c, prompting.IDType(5)) good3.Constraints.PathPattern = mustParsePathPattern(c, "/home/test/no-conflict") rules := []*requestrules.Rule{good1, expired1, good2, expired2, good3} @@ -415,14 +386,14 @@ func (s *requestrulesSuite) TestLoadExpiredRules(c *C) { func (s *requestrulesSuite) TestLoadHappy(c *C) { dbPath := s.prepDBPath(c) - good1 := s.ruleTemplate(c, prompting.IDType(1)) + good1 := s.ruleTemplateWithRead(c, prompting.IDType(1)) // Rules with different users don't conflict - good2 := s.ruleTemplate(c, prompting.IDType(2)) + good2 := s.ruleTemplateWithRead(c, prompting.IDType(2)) good2.User = s.defaultUser + 1 // Rules with different snaps don't conflict - good3 := s.ruleTemplate(c, prompting.IDType(3)) + good3 := s.ruleTemplateWithRead(c, prompting.IDType(3)) good3.Snap = "thunderbird" rules := []*requestrules.Rule{good1, good2, good3} @@ -490,16 +461,21 @@ func (s *requestrulesSuite) TestCloseSaves(c *C) { // disk when DB is closed. constraints := &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/foo"), - Permissions: []string{"read"}, + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, } - rule, err := rdb.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + rule, err := rdb.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) // Check that new rule is on disk s.checkWrittenRuleDB(c, []*requestrules.Rule{rule}) // Mutate rule in memory - rule.Outcome = prompting.OutcomeDeny + rule.Constraints.Permissions["read"].Outcome = prompting.OutcomeDeny // Close DB c.Check(rdb.Close(), IsNil) @@ -611,11 +587,15 @@ func addRuleFromTemplate(c *C, rdb *requestrules.RuleDB, template *addRuleConten partial.Lifespan = template.Lifespan } // Duration default is empty string, so just use partial.Duration - constraints := &prompting.Constraints{ + replyConstraints := &prompting.ReplyConstraints{ PathPattern: mustParsePathPattern(c, partial.PathPattern), Permissions: partial.Permissions, } - return rdb.AddRule(partial.User, partial.Snap, partial.Interface, constraints, partial.Outcome, partial.Lifespan, partial.Duration) + constraints, err := replyConstraints.ToConstraints(partial.Interface, partial.Outcome, partial.Lifespan, partial.Duration) + if err != nil { + return nil, err + } + return rdb.AddRule(partial.User, partial.Snap, partial.Interface, constraints) } func (s *requestrulesSuite) TestAddRuleRemoveRuleDuplicateVariants(c *C) { @@ -844,6 +824,8 @@ func (s *requestrulesSuite) TestAddRuleExpired(c *C) { c.Assert(err, IsNil) c.Assert(good, NotNil) + // TODO: ADD test which tests behavior of rules which partially expire + // Add initial rule which will expire quickly prev, err := addRuleFromTemplate(c, rdb, template, &addRuleContents{ Lifespan: prompting.LifespanTimespan, @@ -911,6 +893,81 @@ func (s *requestrulesSuite) TestAddRuleExpired(c *C) { s.checkNewNotices(c, expectedNoticeInfo) } +func (s *requestrulesSuite) TestAddRulePartiallyExpired(c *C) { + rdb, err := requestrules.New(s.defaultNotifyRule) + c.Assert(err, IsNil) + + user := s.defaultUser + snap := "firefox" + iface := "home" + + constraints1 := &prompting.Constraints{ + PathPattern: mustParsePathPattern(c, "/path/to/{foo,bar}"), + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Duration: "1ns", + }, + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Duration: "1ns", + }, + }, + } + rule1, err := rdb.AddRule(user, snap, iface, constraints1) + c.Assert(err, IsNil) + c.Assert(rule1, NotNil) + s.checkWrittenRuleDB(c, []*requestrules.Rule{rule1}) + s.checkNewNoticesSimple(c, nil, rule1) + + constraints2 := &prompting.Constraints{ + PathPattern: mustParsePathPattern(c, "/path/to/{bar,baz}"), // overlap + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, // conflicting + Lifespan: prompting.LifespanTimespan, + Duration: "1ns", + }, + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Duration: "10s", + }, + }, + } + rule2, err := rdb.AddRule(user, snap, iface, constraints2) + c.Assert(err, IsNil) + c.Assert(rule2, NotNil) + s.checkWrittenRuleDB(c, []*requestrules.Rule{rule1, rule2}) + s.checkNewNoticesSimple(c, nil, rule2) + + // Check that "read" and "execute" were removed from rule1 + _, exists := rule1.Constraints.Permissions["read"] + c.Check(exists, Equals, false) + // Even though "execute" did not conflict, expired entries are removed from + // the variant entry's rule entries whenever a new entry is added to it. + _, exists = rule1.Constraints.Permissions["execute"] + c.Check(exists, Equals, false) + + // Check that "write" was not removed from rule1 + _, exists = rule1.Constraints.Permissions["write"] + c.Check(exists, Equals, true) + + // Check that "read" was not removed from rule2 (even though it's since expired) + _, exists = rule2.Constraints.Permissions["read"] + c.Check(exists, Equals, true) + + // Check that "execute" was not removed from rule2 + _, exists = rule2.Constraints.Permissions["execute"] + c.Check(exists, Equals, true) +} + func (s *requestrulesSuite) TestIsPathAllowedSimple(c *C) { // Target user := s.defaultUser @@ -1130,7 +1187,7 @@ func (s *requestrulesSuite) TestIsPathAllowedExpiration(c *C) { for i := len(addedRules) - 1; i >= 0; i-- { rule := addedRules[i] - expectedOutcome, err := rule.Outcome.AsBool() + expectedOutcome, err := rule.Constraints.Permissions["read"].Outcome.AsBool() c.Check(err, IsNil) // Check that the outcome of the most specific unexpired rule has precedence @@ -1142,24 +1199,24 @@ func (s *requestrulesSuite) TestIsPathAllowedExpiration(c *C) { s.checkNewNoticesSimple(c, nil) // Expire the highest precedence rule - rule.Expiration = time.Now() + rule.Constraints.Permissions["read"].Expiration = time.Now() } } func (s *requestrulesSuite) TestRuleWithID(c *C) { rdb, _ := requestrules.New(s.defaultNotifyRule) - snap := "lxd" - iface := "home" - constraints := &prompting.Constraints{ - PathPattern: mustParsePathPattern(c, "/home/test/Documents/**"), + template := &addRuleContents{ + User: s.defaultUser, + Snap: "lxd", + Interface: "home", + PathPattern: "/home/test/Documents/**", Permissions: []string{"read", "write", "execute"}, + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, } - outcome := prompting.OutcomeAllow - lifespan := prompting.LifespanForever - duration := "" - rule, err := rdb.AddRule(s.defaultUser, snap, iface, constraints, outcome, lifespan, duration) + rule, err := addRuleFromTemplate(c, rdb, template, template) c.Assert(err, IsNil) c.Assert(rule, NotNil) @@ -1246,12 +1303,13 @@ func (s *requestrulesSuite) TestRulesExpired(c *C) { rules := s.prepRuleDBForRulesForSnapInterface(c, rdb) // Set some rules to be expired - rules[0].Lifespan = prompting.LifespanTimespan - rules[0].Expiration = time.Now() - rules[2].Lifespan = prompting.LifespanTimespan - rules[2].Expiration = time.Now() - rules[4].Lifespan = prompting.LifespanTimespan - rules[4].Expiration = time.Now() + // This is brittle, relies on details of the rules added by prepRuleDBForRulesForSnapInterface + rules[0].Constraints.Permissions["read"].Lifespan = prompting.LifespanTimespan + rules[0].Constraints.Permissions["read"].Expiration = time.Now() + rules[2].Constraints.Permissions["read"].Lifespan = prompting.LifespanTimespan + rules[2].Constraints.Permissions["read"].Expiration = time.Now() + rules[4].Constraints.Permissions["read"].Lifespan = prompting.LifespanTimespan + rules[4].Constraints.Permissions["read"].Expiration = time.Now() // Expired rules are excluded from the Rules*() functions c.Check(rdb.Rules(s.defaultUser), DeepEquals, []*requestrules.Rule{rules[1], rules[3]}) @@ -1604,7 +1662,7 @@ func (s *requestrulesSuite) TestPatchRule(c *C) { origRule := *rule // Check that patching with no changes works fine, and updates timestamp - patched, err := rdb.PatchRule(rule.User, rule.ID, nil, prompting.OutcomeUnset, prompting.LifespanUnset, "") + patched, err := rdb.PatchRule(rule.User, rule.ID, nil) c.Assert(err, IsNil) s.checkWrittenRuleDB(c, append(rules[:len(rules)-1], patched)) s.checkNewNoticesSimple(c, nil, rule) @@ -1617,7 +1675,16 @@ func (s *requestrulesSuite) TestPatchRule(c *C) { rule = patched // Check that patching with identical content works fine, and updates timestamp - patched, err = rdb.PatchRule(rule.User, rule.ID, rule.Constraints, rule.Outcome, rule.Lifespan, "") + constraintsPatch := &prompting.RuleConstraintsPatch{ + PathPattern: rule.Constraints.PathPattern, + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: rule.Constraints.Permissions["read"].Outcome, + Lifespan: rule.Constraints.Permissions["read"].Lifespan, + }, + }, + } + patched, err = rdb.PatchRule(rule.User, rule.ID, constraintsPatch) c.Assert(err, IsNil) s.checkWrittenRuleDB(c, append(rules[:len(rules)-1], patched)) s.checkNewNoticesSimple(c, nil, rule) @@ -1629,11 +1696,15 @@ func (s *requestrulesSuite) TestPatchRule(c *C) { rule = patched - newConstraints := &prompting.Constraints{ - PathPattern: rule.Constraints.PathPattern, - Permissions: []string{"read", "execute"}, + constraintsPatch = &prompting.RuleConstraintsPatch{ + Permissions: prompting.PermissionMap{ + "execute": &prompting.PermissionEntry{ + Outcome: rule.Constraints.Permissions["read"].Outcome, + Lifespan: rule.Constraints.Permissions["read"].Lifespan, + }, + }, } - patched, err = rdb.PatchRule(rule.User, rule.ID, newConstraints, prompting.OutcomeUnset, prompting.LifespanUnset, "") + patched, err = rdb.PatchRule(rule.User, rule.ID, constraintsPatch) c.Assert(err, IsNil) s.checkWrittenRuleDB(c, append(rules[:len(rules)-1], patched)) s.checkNewNoticesSimple(c, nil, rule) @@ -1641,12 +1712,36 @@ func (s *requestrulesSuite) TestPatchRule(c *C) { c.Check(patched.Timestamp.Equal(rule.Timestamp), Equals, false) // After making timestamp the same again, check that the rules are identical patched.Timestamp = rule.Timestamp - rule.Constraints = newConstraints + rule.Constraints = &prompting.RuleConstraints{ + PathPattern: patched.Constraints.PathPattern, + Permissions: prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: patched.Constraints.Permissions["read"].Outcome, + Lifespan: patched.Constraints.Permissions["read"].Lifespan, + }, + "execute": &prompting.RulePermissionEntry{ + Outcome: patched.Constraints.Permissions["read"].Outcome, + Lifespan: patched.Constraints.Permissions["read"].Lifespan, + }, + }, + } c.Check(patched, DeepEquals, rule) rule = patched - patched, err = rdb.PatchRule(rule.User, rule.ID, nil, prompting.OutcomeDeny, prompting.LifespanUnset, "") + constraintsPatch = &prompting.RuleConstraintsPatch{ + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, + } + patched, err = rdb.PatchRule(rule.User, rule.ID, constraintsPatch) c.Assert(err, IsNil) s.checkWrittenRuleDB(c, append(rules[:len(rules)-1], patched)) s.checkNewNoticesSimple(c, nil, rule) @@ -1654,12 +1749,27 @@ func (s *requestrulesSuite) TestPatchRule(c *C) { c.Check(patched.Timestamp.Equal(rule.Timestamp), Equals, false) // After making timestamp the same again, check that the rules are identical patched.Timestamp = rule.Timestamp - rule.Outcome = prompting.OutcomeDeny + rule.Constraints.Permissions["read"].Outcome = prompting.OutcomeDeny + rule.Constraints.Permissions["execute"].Outcome = prompting.OutcomeDeny c.Check(patched, DeepEquals, rule) rule = patched - patched, err = rdb.PatchRule(rule.User, rule.ID, nil, prompting.OutcomeUnset, prompting.LifespanTimespan, "10s") + constraintsPatch = &prompting.RuleConstraintsPatch{ + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Duration: "10s", + }, + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanTimespan, + Duration: "10s", + }, + }, + } + patched, err = rdb.PatchRule(rule.User, rule.ID, constraintsPatch) c.Assert(err, IsNil) s.checkWrittenRuleDB(c, append(rules[:len(rules)-1], patched)) s.checkNewNoticesSimple(c, nil, rule) @@ -1667,13 +1777,24 @@ func (s *requestrulesSuite) TestPatchRule(c *C) { c.Check(patched.Timestamp.Equal(rule.Timestamp), Equals, false) // After making timestamp the same again, check that the rules are identical patched.Timestamp = rule.Timestamp - rule.Lifespan = prompting.LifespanTimespan - rule.Expiration = patched.Expiration + rule.Constraints.Permissions["read"].Lifespan = prompting.LifespanTimespan + rule.Constraints.Permissions["execute"].Lifespan = prompting.LifespanTimespan + rule.Constraints.Permissions["read"].Expiration = patched.Constraints.Permissions["read"].Expiration + rule.Constraints.Permissions["execute"].Expiration = patched.Constraints.Permissions["execute"].Expiration c.Check(patched, DeepEquals, rule) rule = patched - patched, err = rdb.PatchRule(rule.User, rule.ID, origRule.Constraints, origRule.Outcome, origRule.Lifespan, "") + constraintsPatch = &prompting.RuleConstraintsPatch{ + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: origRule.Constraints.Permissions["read"].Outcome, + Lifespan: origRule.Constraints.Permissions["read"].Lifespan, + }, + "execute": nil, + }, + } + patched, err = rdb.PatchRule(rule.User, rule.ID, constraintsPatch) c.Assert(err, IsNil) s.checkWrittenRuleDB(c, append(rules[:len(rules)-1], patched)) s.checkNewNoticesSimple(c, nil, rule) @@ -1717,33 +1838,52 @@ func (s *requestrulesSuite) TestPatchRuleErrors(c *C) { rule := rules[len(rules)-1] // Wrong user - result, err := rdb.PatchRule(rule.User+1, rule.ID, nil, prompting.OutcomeUnset, prompting.LifespanUnset, "") + result, err := rdb.PatchRule(rule.User+1, rule.ID, nil) c.Check(err, Equals, prompting_errors.ErrRuleNotAllowed) c.Check(result, IsNil) s.checkWrittenRuleDB(c, rules) s.checkNewNoticesSimple(c, nil) // Wrong ID - result, err = rdb.PatchRule(rule.User, prompting.IDType(1234), nil, prompting.OutcomeUnset, prompting.LifespanUnset, "") + result, err = rdb.PatchRule(rule.User, prompting.IDType(1234), nil) c.Check(err, Equals, prompting_errors.ErrRuleNotFound) c.Check(result, IsNil) s.checkWrittenRuleDB(c, rules) s.checkNewNoticesSimple(c, nil) // Invalid lifespan - result, err = rdb.PatchRule(rule.User, rule.ID, nil, prompting.OutcomeUnset, prompting.LifespanSingle, "") + badPatch := &prompting.RuleConstraintsPatch{ + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanSingle, + }, + }, + } + result, err = rdb.PatchRule(rule.User, rule.ID, badPatch) c.Check(err, ErrorMatches, prompting_errors.NewRuleLifespanSingleError(prompting.SupportedRuleLifespans).Error()) c.Check(result, IsNil) s.checkWrittenRuleDB(c, rules) s.checkNewNoticesSimple(c, nil) // Conflicting rule - conflictingOutcome := prompting.OutcomeDeny - conflictingConstraints := &prompting.Constraints{ - PathPattern: mustParsePathPattern(c, template.PathPattern), - Permissions: []string{"read", "write", "execute"}, + conflictingPatch := &prompting.RuleConstraintsPatch{ + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, } - result, err = rdb.PatchRule(rule.User, rule.ID, conflictingConstraints, conflictingOutcome, prompting.LifespanUnset, "") + result, err = rdb.PatchRule(rule.User, rule.ID, conflictingPatch) c.Check(err, ErrorMatches, fmt.Sprintf("cannot patch rule: %v", prompting_errors.ErrRuleConflict)) c.Check(result, IsNil) s.checkWrittenRuleDB(c, rules) @@ -1753,7 +1893,7 @@ func (s *requestrulesSuite) TestPatchRuleErrors(c *C) { func() { c.Assert(os.Chmod(prompting.StateDir(), 0o500), IsNil) defer os.Chmod(prompting.StateDir(), 0o700) - result, err = rdb.PatchRule(rule.User, rule.ID, nil, prompting.OutcomeUnset, prompting.LifespanUnset, "") + result, err = rdb.PatchRule(rule.User, rule.ID, nil) c.Check(err, NotNil) c.Check(result, IsNil) s.checkWrittenRuleDB(c, rules) @@ -1762,7 +1902,7 @@ func (s *requestrulesSuite) TestPatchRuleErrors(c *C) { // DB Closed c.Assert(rdb.Close(), IsNil) - result, err = rdb.PatchRule(rule.User, rule.ID, nil, prompting.OutcomeUnset, prompting.LifespanUnset, "") + result, err = rdb.PatchRule(rule.User, rule.ID, nil) c.Check(err, Equals, prompting_errors.ErrRulesClosed) c.Check(result, IsNil) s.checkWrittenRuleDB(c, rules) @@ -1803,11 +1943,23 @@ func (s *requestrulesSuite) TestPatchRuleExpired(c *C) { // Patching doesn't conflict with already-expired rules rule := rules[2] - newConstraints := &prompting.Constraints{ - PathPattern: mustParsePathPattern(c, template.PathPattern), - Permissions: []string{"read", "write", "execute"}, + constraintsPatch := &prompting.RuleConstraintsPatch{ + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + "execute": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, } - patched, err := rdb.PatchRule(rule.User, rule.ID, newConstraints, prompting.OutcomeUnset, prompting.LifespanUnset, "") + patched, err := rdb.PatchRule(rule.User, rule.ID, constraintsPatch) c.Assert(err, IsNil) s.checkWrittenRuleDB(c, []*requestrules.Rule{patched}) expectedNotices := []*noticeInfo{ @@ -1827,11 +1979,24 @@ func (s *requestrulesSuite) TestPatchRuleExpired(c *C) { data: nil, }, } - s.checkNewNotices(c, expectedNotices) + s.checkNewNoticesUnordered(c, expectedNotices) // Check that timestamp has changed c.Check(patched.Timestamp.Equal(rule.Timestamp), Equals, false) // After making timestamp the same again, check that the rules are identical patched.Timestamp = rule.Timestamp - rule.Constraints = newConstraints + rule.Constraints.Permissions = prompting.RulePermissionMap{ + "read": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + "execute": &prompting.RulePermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + } c.Check(patched, DeepEquals, rule) } diff --git a/overlord/ifacestate/apparmorprompting/prompting.go b/overlord/ifacestate/apparmorprompting/prompting.go index 405ce443ec0..97461bbabd2 100644 --- a/overlord/ifacestate/apparmorprompting/prompting.go +++ b/overlord/ifacestate/apparmorprompting/prompting.go @@ -34,6 +34,7 @@ import ( "github.com/snapcore/snapd/overlord/state" "github.com/snapcore/snapd/sandbox/apparmor/notify/listener" "github.com/snapcore/snapd/snap/naming" + "github.com/snapcore/snapd/strutil" ) var ( @@ -51,12 +52,12 @@ var ( type Manager interface { Prompts(userID uint32, clientActivity bool) ([]*requestprompts.Prompt, error) PromptWithID(userID uint32, promptID prompting.IDType, clientActivity bool) (*requestprompts.Prompt, error) - HandleReply(userID uint32, promptID prompting.IDType, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string, clientActivity bool) ([]prompting.IDType, error) + HandleReply(userID uint32, promptID prompting.IDType, replyConstraints *prompting.ReplyConstraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string, clientActivity bool) ([]prompting.IDType, error) Rules(userID uint32, snap string, iface string) ([]*requestrules.Rule, error) - AddRule(userID uint32, snap string, iface string, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string) (*requestrules.Rule, error) + AddRule(userID uint32, snap string, iface string, constraints *prompting.Constraints) (*requestrules.Rule, error) RemoveRules(userID uint32, snap string, iface string) ([]*requestrules.Rule, error) RuleWithID(userID uint32, ruleID prompting.IDType) (*requestrules.Rule, error) - PatchRule(userID uint32, ruleID prompting.IDType, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string) (*requestrules.Rule, error) + PatchRule(userID uint32, ruleID prompting.IDType, constraintsPatch *prompting.RuleConstraintsPatch) (*requestrules.Rule, error) RemoveRule(userID uint32, ruleID prompting.IDType) (*requestrules.Rule, error) } @@ -221,6 +222,7 @@ func (m *InterfacesRequestsManager) handleListenerReq(req *listener.Request) err matchedDenyRule := false for _, perm := range permissions { + // TODO: move this for-loop to a helper in requestrules if yesNo, err := m.rules.IsPathAllowed(userID, snap, iface, path, perm); err == nil { if !yesNo { matchedDenyRule = true @@ -311,29 +313,7 @@ func (m *InterfacesRequestsManager) disconnect() error { m.rules = nil } - return errorsJoin(errs...) -} - -// errorsJoin returns an error that wraps the given errors. -// Any nil error values are discarded. -// errorsJoin returns nil if every value in errs is nil. -// -// TODO: replace with errors.Join() once we're on golang v1.20+ -func errorsJoin(errs ...error) error { - var nonNilErrs []error - for _, e := range errs { - if e != nil { - nonNilErrs = append(nonNilErrs, e) - } - } - if len(nonNilErrs) == 0 { - return nil - } - err := nonNilErrs[0] - for _, e := range nonNilErrs[1:] { - err = fmt.Errorf("%w\n%v", err, e) - } - return err + return strutil.JoinErrors(errs...) } // Stop closes the listener, prompt DB, and rule DB. Stop is idempotent, and @@ -372,7 +352,7 @@ func (m *InterfacesRequestsManager) PromptWithID(userID uint32, promptID prompti // // If clientActivity is true, reset the expiration timeout for prompts for // the given user. -func (m *InterfacesRequestsManager) HandleReply(userID uint32, promptID prompting.IDType, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string, clientActivity bool) (satisfiedPromptIDs []prompting.IDType, retErr error) { +func (m *InterfacesRequestsManager) HandleReply(userID uint32, promptID prompting.IDType, replyConstraints *prompting.ReplyConstraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string, clientActivity bool) (satisfiedPromptIDs []prompting.IDType, retErr error) { m.lock.Lock() defer m.lock.Unlock() @@ -381,10 +361,12 @@ func (m *InterfacesRequestsManager) HandleReply(userID uint32, promptID promptin return nil, err } - // Outcome and lifesnap are validated while unmarshalling, and duration is - // validated when the rule is being added. So only need to validate - // constraints. - if err := constraints.ValidateForInterface(prompt.Interface); err != nil { + // Validate reply constraints and convert them to Constraints, which have + // dedicated PermissionEntry values for each permission in the reply. + // Outcome and lifespan are validated while unmarshalling, and duration is + // validated against the given lifespan when constructing the Constraints. + constraints, err := replyConstraints.ToConstraints(prompt.Interface, outcome, lifespan, duration) + if err != nil { return nil, err } @@ -412,7 +394,7 @@ func (m *InterfacesRequestsManager) HandleReply(userID uint32, promptID promptin if !contained { return nil, &prompting_errors.RequestedPermissionsNotMatchedError{ Requested: prompt.Constraints.RemainingPermissions(), - Replied: constraints.Permissions, + Replied: replyConstraints.Permissions, // equivalent to keys of constraints.Permissions } } @@ -422,7 +404,7 @@ func (m *InterfacesRequestsManager) HandleReply(userID uint32, promptID promptin var newRule *requestrules.Rule if lifespan != prompting.LifespanSingle { // Check that adding the rule doesn't conflict with other rules - newRule, err = m.rules.AddRule(userID, prompt.Snap, prompt.Interface, constraints, outcome, lifespan, duration) + newRule, err = m.rules.AddRule(userID, prompt.Snap, prompt.Interface, constraints) if err != nil { // Rule conflicts with existing rule (at least one identical pattern // variant and permission). This should be considered a bad reply, @@ -460,7 +442,7 @@ func (m *InterfacesRequestsManager) applyRuleToOutstandingPrompts(rule *requestr Snap: rule.Snap, Interface: rule.Interface, } - satisfiedPromptIDs, err := m.prompts.HandleNewRule(metadata, rule.Constraints, rule.Outcome) + satisfiedPromptIDs, err := m.prompts.HandleNewRule(metadata, rule.Constraints) if err != nil { // The rule's constraints and outcome were already validated, so an // error should not occur here unless the prompt DB was already closed. @@ -493,11 +475,11 @@ func (m *InterfacesRequestsManager) Rules(userID uint32, snap string, iface stri // AddRule creates a new rule with the given contents and then checks it against // outstanding prompts, resolving any prompts which it satisfies. -func (m *InterfacesRequestsManager) AddRule(userID uint32, snap string, iface string, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string) (*requestrules.Rule, error) { +func (m *InterfacesRequestsManager) AddRule(userID uint32, snap string, iface string, constraints *prompting.Constraints) (*requestrules.Rule, error) { m.lock.Lock() defer m.lock.Unlock() - newRule, err := m.rules.AddRule(userID, snap, iface, constraints, outcome, lifespan, duration) + newRule, err := m.rules.AddRule(userID, snap, iface, constraints) if err != nil { return nil, err } @@ -540,11 +522,11 @@ func (m *InterfacesRequestsManager) RuleWithID(userID uint32, ruleID prompting.I // PatchRule updates the rule with the given ID using the provided contents. // Any of the given fields which are empty/nil are not updated in the rule. -func (m *InterfacesRequestsManager) PatchRule(userID uint32, ruleID prompting.IDType, constraints *prompting.Constraints, outcome prompting.OutcomeType, lifespan prompting.LifespanType, duration string) (*requestrules.Rule, error) { +func (m *InterfacesRequestsManager) PatchRule(userID uint32, ruleID prompting.IDType, constraintsPatch *prompting.RuleConstraintsPatch) (*requestrules.Rule, error) { m.lock.Lock() defer m.lock.Unlock() - patchedRule, err := m.rules.PatchRule(userID, ruleID, constraints, outcome, lifespan, duration) + patchedRule, err := m.rules.PatchRule(userID, ruleID, constraintsPatch) if err != nil { return nil, err } diff --git a/overlord/ifacestate/apparmorprompting/prompting_test.go b/overlord/ifacestate/apparmorprompting/prompting_test.go index 3cc02d9567a..4f4449fe000 100644 --- a/overlord/ifacestate/apparmorprompting/prompting_test.go +++ b/overlord/ifacestate/apparmorprompting/prompting_test.go @@ -271,7 +271,7 @@ func (s *apparmorpromptingSuite) TestHandleReplySimple(c *C) { req, prompt := s.simulateRequest(c, reqChan, mgr, &listener.Request{}, false) // Reply to the request - constraints := prompting.Constraints{ + constraints := prompting.ReplyConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), Permissions: []string{"read"}, } @@ -422,7 +422,7 @@ func (s *apparmorpromptingSuite) TestHandleReplyErrors(c *C) { c.Check(result, IsNil) // Invalid constraints - invalidConstraints := prompting.Constraints{ + invalidConstraints := prompting.ReplyConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), Permissions: []string{"foo"}, } @@ -431,7 +431,7 @@ func (s *apparmorpromptingSuite) TestHandleReplyErrors(c *C) { c.Check(result, IsNil) // Path not matched - badPatternConstraints := prompting.Constraints{ + badPatternConstraints := prompting.ReplyConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/other"), Permissions: []string{"read"}, } @@ -440,7 +440,7 @@ func (s *apparmorpromptingSuite) TestHandleReplyErrors(c *C) { c.Check(result, IsNil) // Permissions not matched - badPermissionConstraints := prompting.Constraints{ + badPermissionConstraints := prompting.ReplyConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/foo"), Permissions: []string{"write"}, } @@ -451,11 +451,21 @@ func (s *apparmorpromptingSuite) TestHandleReplyErrors(c *C) { // Conflicting rule // For this, need to add another rule to the DB first, then try to reply // with a rule which conflicts with it. Reuse badPatternConstraints. - newRule, err := mgr.AddRule(s.defaultUser, "firefox", "home", &badPatternConstraints, prompting.OutcomeAllow, prompting.LifespanTimespan, "10s") + anotherConstraints := prompting.Constraints{ + PathPattern: mustParsePathPattern(c, "/home/test/other"), + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanTimespan, + Duration: "10s", + }, + }, + } + newRule, err := mgr.AddRule(s.defaultUser, "firefox", "home", &anotherConstraints) c.Assert(err, IsNil) c.Assert(newRule, NotNil) conflictingOutcome := prompting.OutcomeDeny - conflictingConstraints := prompting.Constraints{ + conflictingConstraints := prompting.ReplyConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/{foo,other}"), Permissions: []string{"read"}, } @@ -480,17 +490,27 @@ func (s *apparmorpromptingSuite) TestExistingRuleAllowsNewPrompt(c *C) { // Add allow rule to match read permission constraints := &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), - Permissions: []string{"read"}, - } - _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) // Add allow rule to match write permission constraints = &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), - Permissions: []string{"write"}, - } - _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) // Create request for read and write @@ -529,7 +549,7 @@ func (s *apparmorpromptingSuite) checkRecordedPromptNotices(c *C, since time.Tim After: since, }) s.st.Unlock() - c.Check(n, HasLen, count) + c.Check(n, HasLen, count, Commentf("%+v", n)) } func (s *apparmorpromptingSuite) checkRecordedRuleUpdateNotices(c *C, since time.Time, count int) { @@ -539,7 +559,7 @@ func (s *apparmorpromptingSuite) checkRecordedRuleUpdateNotices(c *C, since time After: since, }) s.st.Unlock() - c.Check(n, HasLen, count) + c.Check(n, HasLen, count, Commentf("%+v", n)) } func (s *apparmorpromptingSuite) TestExistingRulePartiallyAllowsNewPrompt(c *C) { @@ -552,9 +572,14 @@ func (s *apparmorpromptingSuite) TestExistingRulePartiallyAllowsNewPrompt(c *C) // Add rule to match read permission constraints := &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), - Permissions: []string{"read"}, - } - _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) // Do NOT add rule to match write permission @@ -581,9 +606,14 @@ func (s *apparmorpromptingSuite) TestExistingRulePartiallyDeniesNewPrompt(c *C) // Add deny rule to match read permission constraints := &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), - Permissions: []string{"read"}, - } - _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeDeny, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, + } + _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) // Add no rule for write permissions @@ -625,17 +655,27 @@ func (s *apparmorpromptingSuite) TestExistingRulesMixedMatchNewPromptDenies(c *C // Add deny rule to match read permission constraints := &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), - Permissions: []string{"read"}, - } - _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeDeny, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, + } + _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) // Add allow rule for write permissions constraints = &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), - Permissions: []string{"write"}, - } - _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + _, err = mgr.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) // Create request for read and write @@ -701,9 +741,14 @@ func (s *apparmorpromptingSuite) TestNewRuleAllowExistingPrompt(c *C) { whenSent := time.Now() constraints := &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), - Permissions: []string{"read"}, - } - rule, err := mgr.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + rule, err := mgr.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) // Check that kernel received a reply @@ -775,9 +820,14 @@ func (s *apparmorpromptingSuite) TestNewRuleDenyExistingPrompt(c *C) { whenSent := time.Now() constraints := &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), - Permissions: []string{"read"}, - } - rule, err := mgr.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeDeny, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeDeny, + Lifespan: prompting.LifespanForever, + }, + }, + } + rule, err := mgr.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) // Check that kernel received two replies @@ -843,7 +893,7 @@ func (s *apparmorpromptingSuite) TestReplyNewRuleHandlesExistingPrompt(c *C) { // Reply to read prompt with denial whenSent := time.Now() - constraints := &prompting.Constraints{ + constraints := &prompting.ReplyConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), Permissions: []string{"read"}, } @@ -926,7 +976,7 @@ func (s *apparmorpromptingSuite) testReplyRuleHandlesFuturePrompts(c *C, outcome // Reply to read prompt with denial whenSent := time.Now() - constraints := &prompting.Constraints{ + constraints := &prompting.ReplyConstraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), Permissions: []string{"read", "write"}, } @@ -1030,9 +1080,14 @@ func (s *apparmorpromptingSuite) TestRequestMerged(c *C) { // Add rule to satisfy the read permission constraints := &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), - Permissions: []string{"read"}, - } - _, err = mgr.AddRule(s.defaultUser, prompt.Snap, prompt.Interface, constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + _, err = mgr.AddRule(s.defaultUser, prompt.Snap, prompt.Interface, constraints) c.Assert(err, IsNil) // Create identical request again, it should merge even though some @@ -1090,27 +1145,42 @@ func (s *apparmorpromptingSuite) prepManagerWithRules(c *C) (mgr *apparmorprompt // Add rule for firefox and home constraints := &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/1"), - Permissions: []string{"read"}, - } - rule1, err := mgr.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + rule1, err := mgr.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) rules = append(rules, rule1) // Add rule for thunderbird and home constraints = &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/2"), - Permissions: []string{"read"}, - } - rule2, err := mgr.AddRule(s.defaultUser, "thunderbird", "home", constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + rule2, err := mgr.AddRule(s.defaultUser, "thunderbird", "home", constraints) c.Assert(err, IsNil) rules = append(rules, rule2) // Add rule for firefox and camera constraints = &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/3"), - Permissions: []string{"read"}, - } - rule3, err := mgr.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + rule3, err := mgr.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) // Since camera interface isn't supported yet, must adjust the interface // after the rule has been created. This abuses implementation details of @@ -1121,9 +1191,14 @@ func (s *apparmorpromptingSuite) prepManagerWithRules(c *C) (mgr *apparmorprompt // Add rule for firefox and home, but for a different user constraints = &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/4"), - Permissions: []string{"read"}, - } - rule4, err := mgr.AddRule(s.defaultUser+1, "firefox", "home", constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + rule4, err := mgr.AddRule(s.defaultUser+1, "firefox", "home", constraints) c.Assert(err, IsNil) rules = append(rules, rule4) @@ -1219,16 +1294,24 @@ func (s *apparmorpromptingSuite) TestAddRuleWithIDPatchRemove(c *C) { whenAdded := time.Now() constraints := &prompting.Constraints{ PathPattern: mustParsePathPattern(c, "/home/test/**"), - Permissions: []string{"write"}, - } - rule, err := mgr.AddRule(s.defaultUser, "firefox", "home", constraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + rule, err := mgr.AddRule(s.defaultUser, "firefox", "home", constraints) c.Assert(err, IsNil) s.checkRecordedRuleUpdateNotices(c, whenAdded, 1) + s.checkRecordedPromptNotices(c, whenAdded, 0) // Test RuleWithID + whenAccessed := time.Now() retrieved, err := mgr.RuleWithID(rule.User, rule.ID) c.Assert(err, IsNil) c.Assert(retrieved, Equals, rule) + s.checkRecordedRuleUpdateNotices(c, whenAccessed, 0) // Check prompt still exists and no prompt notices recorded since before // the rule was added @@ -1236,15 +1319,24 @@ func (s *apparmorpromptingSuite) TestAddRuleWithIDPatchRemove(c *C) { retrievedPrompt, err := mgr.PromptWithID(s.defaultUser, prompt.ID, clientActivity) c.Assert(err, IsNil) c.Assert(retrievedPrompt, Equals, prompt) - s.checkRecordedPromptNotices(c, whenAdded, 0) + s.checkRecordedPromptNotices(c, whenAccessed, 0) // Patch rule to now cover the outstanding prompt whenPatched := time.Now() - newConstraints := &prompting.Constraints{ + constraintsPatch := &prompting.RuleConstraintsPatch{ PathPattern: mustParsePathPattern(c, "/home/test/{foo,bar,baz}"), - Permissions: []string{"read", "write"}, - } - patched, err := mgr.PatchRule(s.defaultUser, rule.ID, newConstraints, prompting.OutcomeAllow, prompting.LifespanForever, "") + Permissions: prompting.PermissionMap{ + "read": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + "write": &prompting.PermissionEntry{ + Outcome: prompting.OutcomeAllow, + Lifespan: prompting.LifespanForever, + }, + }, + } + patched, err := mgr.PatchRule(s.defaultUser, rule.ID, constraintsPatch) c.Assert(err, IsNil) s.checkRecordedRuleUpdateNotices(c, whenPatched, 1) diff --git a/strutil/joinerrors.go b/strutil/joinerrors.go new file mode 100644 index 00000000000..7bff7ff53b5 --- /dev/null +++ b/strutil/joinerrors.go @@ -0,0 +1,49 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package strutil + +import ( + "fmt" +) + +// JoinErrors returns an error that wraps the given errors. +// Any nil error values are discarded. +// Join returns nil if every value in errs is nil. +// +// TODO: replace with errors.Join() once we're on golang v1.20+ +// +// This is a lossy implementation of errors.Join, where only the first non-nil +// error is preserved in a state which can be unwrapped. +func JoinErrors(errs ...error) error { + var nonNilErrs []error + for _, e := range errs { + if e != nil { + nonNilErrs = append(nonNilErrs, e) + } + } + if len(nonNilErrs) == 0 { + return nil + } + err := nonNilErrs[0] + for _, e := range nonNilErrs[1:] { + err = fmt.Errorf("%w\n%v", err, e) + } + return err +} diff --git a/strutil/joinerrors_test.go b/strutil/joinerrors_test.go new file mode 100644 index 00000000000..dea4f11c38a --- /dev/null +++ b/strutil/joinerrors_test.go @@ -0,0 +1,69 @@ +// -*- Mode: Go; indent-tabs-mode: t -*- + +/* + * Copyright (C) 2024 Canonical Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package strutil_test + +import ( + "errors" + + . "gopkg.in/check.v1" + + "github.com/snapcore/snapd/strutil" +) + +type joinErrorsSuite struct{} + +var _ = Suite(&joinErrorsSuite{}) + +func (s *joinErrorsSuite) TestJoin(c *C) { + errs := []error{ + errors.New("foo"), + errors.New("bar"), + errors.New("baz"), + } + for _, testCase := range []struct { + errors []error + wrapped error + errStr string + }{ + { + errs, + errs[0], + "foo\nbar\nbaz", + }, + { + []error{nil, errs[2], nil, errs[1], nil}, + errs[2], + "baz\nbar", + }, + { + []error{nil, nil, nil}, + nil, + "", + }, + } { + joined := strutil.JoinErrors(testCase.errors...) + c.Check(errors.Is(joined, testCase.wrapped), Equals, true, Commentf("testCase: %+v", testCase)) + if testCase.errStr != "" { + c.Check(joined, ErrorMatches, testCase.errStr) + } else { + c.Check(joined, IsNil) + } + } +} diff --git a/tests/main/apparmor-prompting-snapd-startup/task.yaml b/tests/main/apparmor-prompting-snapd-startup/task.yaml index 9d7ee489e51..32bf68388b8 100644 --- a/tests/main/apparmor-prompting-snapd-startup/task.yaml +++ b/tests/main/apparmor-prompting-snapd-startup/task.yaml @@ -31,10 +31,82 @@ debug: | execute: | RULES_PATH="/var/lib/snapd/interfaces-requests/request-rules.json" - echo "Write two rules to disk, one of which is expired" + echo "Write three rules to disk, one of which is partially expired," + echo "another is fully expired, and the last is not expired whatsoever" mkdir -p "$(dirname $RULES_PATH)" - echo '{"rules":[{"id":"0000000000000002","timestamp":"2004-10-20T14:05:08.901174186-05:00","user":1000,"snap":"shellcheck","interface":"home","constraints":{"path-pattern":"/home/test/Projects/**","permissions":["read"]},"outcome":"allow","lifespan":"forever","expiration":"0001-01-01T00:00:00Z"},{"id":"0000000000000003","timestamp":"2004-10-20T16:47:32.138415627-05:00","user":1000,"snap":"firefox","interface":"home","constraints":{"path-pattern":"/home/test/Downloads/**","permissions":["read","write"]},"outcome":"allow","lifespan":"timespan","expiration":"2005-04-08T00:00:00Z"}]}' | \ - tee "$RULES_PATH" + echo '{ + "rules": [ + { + "id": "0000000000000002", + "timestamp": "2004-10-20T14:05:08.901174186-05:00", + "user": 1000, + "snap": "shellcheck", + "interface": "home", + "constraints": { + "path-pattern": "/home/test/Projects/**", + "permissions": { + "read": { + "outcome": "allow", + "lifespan": "forever" + }, + "write": { + "outcome": "allow", + "lifespan": "timespan", + "expiration": "2005-04-08T00:00:00Z" + }, + "execute": { + "outcome": "deny", + "lifespan": "timespan", + "expiration": "9999-01-01T00:00:00Z" + } + } + } + }, + { + "id": "0000000000000003", + "timestamp": "2004-10-20T16:47:32.138415627-05:00", + "user": 1000, + "snap": "firefox", + "interface": "home", + "constraints": { + "path-pattern": "/home/test/Downloads/**", + "permissions": { + "read": { + "outcome": "deny", + "lifespan": "timespan", + "expiration":"2005-04-08T00:00:00Z" + }, + "write": { + "outcome": "allow", + "lifespan": "timespan", + "expiration": "2005-04-08T00:00:00Z" + } + } + } + }, + { + "id": "0000000000000005", + "timestamp": "2004-10-20T17:27:41.932269962-05:00", + "user": 1000, + "snap": "thunderbird", + "interface": "home", + "constraints": { + "path-pattern": "/home/test/Downloads/thunderbird.tmp/**", + "permissions": { + "read": { + "outcome": "allow", + "lifespan": "forever" + }, + "write": { + "outcome": "allow", + "lifespan": "timespan", + "expiration": "9999-01-01T00:00:00Z" + } + } + } + } + ] + }' | tee "$RULES_PATH" # Prompting is unsupported everywhere but the Ubuntu non-core systems with # kernels which support apparmor prompting @@ -70,24 +142,72 @@ execute: | snap debug api "/v2/system-info" | gojq '.result.features."apparmor-prompting".enabled' | MATCH true # Write expected rules after the expired rule has been removed - echo '{"rules":[{"id":"0000000000000002","timestamp":"2004-10-20T14:05:08.901174186-05:00","user":1000,"snap":"shellcheck","interface":"home","constraints":{"path-pattern":"/home/test/Projects/**","permissions":["read"]},"outcome":"allow","lifespan":"forever","expiration":"0001-01-01T00:00:00Z"}]}' | \ - gojq | tee expected.json + echo '{ + "rules": [ + { + "id": "0000000000000002", + "timestamp": "2004-10-20T14:05:08.901174186-05:00", + "user": 1000, + "snap": "shellcheck", + "interface": "home", + "constraints": { + "path-pattern": "/home/test/Projects/**", + "permissions": { + "read": { + "outcome": "allow", + "lifespan": "forever", + "expiration": "0001-01-01T00:00:00Z" + }, + "execute": { + "outcome": "deny", + "lifespan": "timespan", + "expiration": "9999-01-01T00:00:00Z" + } + } + } + }, + { + "id": "0000000000000005", + "timestamp": "2004-10-20T17:27:41.932269962-05:00", + "user": 1000, + "snap": "thunderbird", + "interface": "home", + "constraints": { + "path-pattern": "/home/test/Downloads/thunderbird.tmp/**", + "permissions": { + "read": { + "outcome": "allow", + "lifespan": "forever", + "expiration": "0001-01-01T00:00:00Z" + }, + "write": { + "outcome": "allow", + "lifespan": "timespan", + "expiration": "9999-01-01T00:00:00Z" + } + } + } + } + ] + }' | gojq | tee expected.json # Parse existing rules through (go)jq so they can be compared - gojq < "$RULES_PATH" > current.json echo "Check that rules on disk match what is expected" + gojq < "$RULES_PATH" > current.json diff expected.json current.json - echo "Check that we received two notices" + echo "Check that we received three notices, one of which marked a rule as expired" snap debug api --fail "/v2/notices?after=$CURRTIME&types=interfaces-requests-rule-update&user-id=1000" | gojq snap debug api "/v2/notices?after=$CURRTIME&types=interfaces-requests-rule-update&user-id=1000" | \ - gojq '.result | length' | MATCH 2 + gojq '.result | length' | MATCH 3 snap debug api "/v2/notices?after=$CURRTIME&types=interfaces-requests-rule-update&user-id=1000" | \ gojq '.result' | grep -c '"removed": "expired"' | MATCH 1 - echo "Check that only the former rule is still valid (must be done with UID 1000)" - sudo -iu '#1000' snap debug api /v2/interfaces/requests/rules | gojq '.result | length' | MATCH 1 - sudo -iu '#1000' snap debug api /v2/interfaces/requests/rules | gojq '.result[0].id' | MATCH "0000000000000002" + echo "Check that only the first and last rules are still valid (must be done with UID 1000)" + snap debug api --fail "/v2/interfaces/requests/rules?user-id=1000" | gojq + snap debug api "/v2/interfaces/requests/rules?user-id=1000" | gojq '.result | length' | MATCH 2 + snap debug api "/v2/interfaces/requests/rules?user-id=1000" | gojq '.result[0].id' | MATCH "0000000000000002" + snap debug api "/v2/interfaces/requests/rules?user-id=1000" | gojq '.result[1].id' | MATCH "0000000000000005" echo "Stop snapd and ensure it is not in failure mode" systemctl stop snapd.service snapd.socket @@ -106,18 +226,22 @@ execute: | snap debug api "/v2/system-info" | gojq '.result.features."apparmor-prompting".enabled' | MATCH true echo "Check that rules on disk still match what is expected" + gojq < "$RULES_PATH" > current.json diff expected.json current.json - echo "Check that we received one notices for the non-expired rule" + echo "Check that we received two notices for the non-expired rules" snap debug api --fail "/v2/notices?after=$CURRTIME&types=interfaces-requests-rule-update&user-id=1000" | gojq snap debug api "/v2/notices?after=$CURRTIME&types=interfaces-requests-rule-update&user-id=1000" | \ - gojq '.result | length' | MATCH 1 + gojq '.result | length' | MATCH 2 snap debug api "/v2/notices?after=$CURRTIME&types=interfaces-requests-rule-update&user-id=1000" | \ gojq '.result[0].key' | MATCH "0000000000000002" + snap debug api "/v2/notices?after=$CURRTIME&types=interfaces-requests-rule-update&user-id=1000" | \ + gojq '.result[1].key' | MATCH "0000000000000005" - echo "Check that only the non-expired rule is still valid (must be done with UID 1000)" - sudo -iu '#1000' snap debug api /v2/interfaces/requests/rules | gojq '.result | length' | MATCH 1 - sudo -iu '#1000' snap debug api /v2/interfaces/requests/rules | gojq '.result[0].id' | MATCH "0000000000000002" + echo "Check that only the non-expired rules are still valid (must be done with UID 1000)" + snap debug api "/v2/interfaces/requests/rules?user-id=1000" | gojq '.result | length' | MATCH 2 + snap debug api "/v2/interfaces/requests/rules?user-id=1000" | gojq '.result[0].id' | MATCH "0000000000000002" + snap debug api "/v2/interfaces/requests/rules?user-id=1000" | gojq '.result[1].id' | MATCH "0000000000000005" echo '### Simulate failure to open interfaces requests manager ###' @@ -144,12 +268,12 @@ execute: | snap debug api "/v2/system-info" | gojq '.result.features."apparmor-prompting".enabled' | MATCH true echo "Check that rules on disk still match what is expected" + gojq < "$RULES_PATH" > current.json diff expected.json current.json echo "Check that accessing a prompting endpoint results in an expected error" - sudo -iu '#1000' snap debug api /v2/interfaces/requests/rules | gojq '."status-code"' | MATCH 500 - sudo -iu '#1000' snap debug api /v2/interfaces/requests/rules | \ - gojq '.result.message' | MATCH -i "Apparmor Prompting is not running" + snap debug api "/v2/interfaces/requests/rules?user-id=1000" | gojq '."status-code"' | MATCH 500 + snap debug api "/v2/interfaces/requests/rules?user-id=1000" | gojq '.result.message' | MATCH -i "Apparmor Prompting is not running" echo '### Remove the corrupted max prompt ID file and check that prompting backends can start again ###' @@ -173,15 +297,19 @@ execute: | snap debug api "/v2/system-info" | gojq '.result.features."apparmor-prompting".enabled' | MATCH true echo "Check that rules on disk still match what is expected" + gojq < "$RULES_PATH" > current.json diff expected.json current.json - echo "Check that we received one notices for the non-expired rule" + echo "Check that we received one notice each for the non-expired rules" snap debug api --fail "/v2/notices?after=$CURRTIME&types=interfaces-requests-rule-update&user-id=1000" | gojq snap debug api "/v2/notices?after=$CURRTIME&types=interfaces-requests-rule-update&user-id=1000" | \ - gojq '.result | length' | MATCH 1 + gojq '.result | length' | MATCH 2 snap debug api "/v2/notices?after=$CURRTIME&types=interfaces-requests-rule-update&user-id=1000" | \ gojq '.result[0].key' | MATCH "0000000000000002" + snap debug api "/v2/notices?after=$CURRTIME&types=interfaces-requests-rule-update&user-id=1000" | \ + gojq '.result[1].key' | MATCH "0000000000000005" - echo "Check that the non-expired rule is still valid (must be done with UID 1000)" - sudo -iu '#1000' snap debug api /v2/interfaces/requests/rules | gojq '.result | length' | MATCH 1 - sudo -iu '#1000' snap debug api /v2/interfaces/requests/rules | gojq '.result[0].id' | MATCH "0000000000000002" + echo "Check that the non-expired rules are still valid (must be done with UID 1000)" + snap debug api "/v2/interfaces/requests/rules?user-id=1000" | gojq '.result | length' | MATCH 2 + snap debug api "/v2/interfaces/requests/rules?user-id=1000" | gojq '.result[0].id' | MATCH "0000000000000002" + snap debug api "/v2/interfaces/requests/rules?user-id=1000" | gojq '.result[1].id' | MATCH "0000000000000005" diff --git a/tests/main/interfaces-snap-interfaces-requests-control/task.yaml b/tests/main/interfaces-snap-interfaces-requests-control/task.yaml index f360c152e51..b8b6d0d239a 100644 --- a/tests/main/interfaces-snap-interfaces-requests-control/task.yaml +++ b/tests/main/interfaces-snap-interfaces-requests-control/task.yaml @@ -114,8 +114,33 @@ execute: | # XXX: creating rules requires polkit authentication, so for now, use snap debug api instead of api-client # echo "Check snap can create rule via /v2/interfaces/requests/rules" - # api-client --socket /run/snapd-snap.socket --method=POST '{"action":"add","rule":{"snap":"api-client","interface":"home","constraints":{"path-pattern":"/path/to/file","permissions":["read","write","execute"]},"outcome":"allow","lifespan":"forever"}}' "/v2/interfaces/requests/rules" > result.json - echo '{"action":"add","rule":{"snap":"api-client","interface":"home","constraints":{"path-pattern":"/path/to/file","permissions":["read","write","execute"]},"outcome":"allow","lifespan":"forever"}}' | snap debug api -X POST -H 'Content-Type: application/json' "/v2/interfaces/requests/rules" | \ + # api-client --socket /run/snapd-snap.socket --method=POST '{"action":"add","rule":{"snap":"api-client","interface":"home","constraints":{"path-pattern":"/path/to/file","permissions":{"read":{"outcome":"allow","lifespan":"forever"},"write":{"outcome":"deny","lifespan":"forever"},"execute":{"outcome":"allow","lifespan":"timespan","duration":"1h"}}}}}' "/v2/interfaces/requests/rules" > result.json + echo '{ + "action": "add", + "rule": { + "snap": "api-client", + "interface": "home", + "constraints": { + "path-pattern": "/path/to/file", + "permissions": { + "read": { + "outcome": "allow", + "lifespan": "forever" + }, + "write": { + "outcome": "deny", + "lifespan": "forever" + }, + "execute": { + "outcome": "allow", + "lifespan": "timespan", + "duration": "1h" + } + } + } + } + }' | \ + snap debug api -X POST -H 'Content-Type: application/json' "/v2/interfaces/requests/rules" | \ tee result.json gojq '."status-code"' < result.json | MATCH '^'"$EXPECTED_HTTP_CODE"'$' RULE_ID=$(gojq '."result"."id"' < result.json | tr -d '"') @@ -137,13 +162,16 @@ execute: | gojq '."status-code"' | MATCH '^403$' api-client --socket /run/snapd-snap.socket "/v2/notices?types=interfaces-requests-rule-update" | \ gojq '."status-code"' | MATCH '^403$' - api-client --socket /run/snapd-snap.socket "/v2/system-info" | gojq '."status-code"' | MATCH '^403$' - api-client --socket /run/snapd-snap.socket "/v2/snaps/$SNAP_NAME" | gojq '."status-code"' | MATCH '^403$' + api-client --socket /run/snapd-snap.socket "/v2/system-info" | \ + gojq '."status-code"' | MATCH '^403$' + api-client --socket /run/snapd-snap.socket "/v2/snaps/$SNAP_NAME" | \ + gojq '."status-code"' | MATCH '^403$' api-client --socket /run/snapd-snap.socket "/v2/interfaces/requests/prompts" | \ gojq '."status-code"' | MATCH '^403$' # Try to access an arbitrary prompt ID, should fail with 403 rather than 404 api-client --socket /run/snapd-snap.socket "/v2/interfaces/requests/prompts/1234123412341234" | \ gojq '."status-code"' | MATCH '^403$' - api-client --socket /run/snapd-snap.socket "/v2/interfaces/requests/rules" | gojq '."status-code"' | MATCH '^403$' + api-client --socket /run/snapd-snap.socket "/v2/interfaces/requests/rules" | \ + gojq '."status-code"' | MATCH '^403$' api-client --socket /run/snapd-snap.socket "/v2/interfaces/requests/rules/$RULE_ID" | \ gojq '."status-code"' | MATCH '^403$'