-
Notifications
You must be signed in to change notification settings - Fork 582
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
i/p/requestrules,o/i/apparmorprompting: allow overlapping rules #14538
i/p/requestrules,o/i/apparmorprompting: allow overlapping rules #14538
Conversation
Codecov ReportAll modified and coverable lines are covered by tests ✅
Additional details and impacted files@@ Coverage Diff @@
## master #14538 +/- ##
==========================================
+ Coverage 78.89% 78.95% +0.06%
==========================================
Files 1083 1084 +1
Lines 146377 146614 +237
==========================================
+ Hits 115479 115764 +285
+ Misses 23695 23656 -39
+ Partials 7203 7194 -9
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. |
type variantEntry struct { | ||
Variant patterns.PatternVariant | ||
RuleID prompting.IDType | ||
RuleIDs map[prompting.IDType]prompting.OutcomeType |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why do we map to OutcomeType if we expects all "Outcome"s" to be the same? what am I missing? should outcome be stored separately? am I reading the comments wrong?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right, this is a bit odd. The rationale behind mapping rule ID to outcome was to less-tightly couple outcome to the entry itself, in case the rules expire, and also to allow the coexistence of expired rules for one outcome and non-expired rules for another.
But it's true, whenever we add a new rule, we clean up expired rules, so we'll never have coexisting expired and non-expired rules for different outcomes. And it's definitely clumsy to look up the outcome from a particular rule ID. So I think storing the outcome associated with a variant entry would be cleaner and clearer.
The other rationale was to make it easier to tell what the outcome of a rule was without trying to look it up (where it might not exist if there's an inconsistency). But this isn't a very good reason, as we look up the rule to check its expiration immediately before, so it's fine to look up the outcome from the rule instead of storing it in the map value. But really, we don't need this at all, since we know that the outcome of every rule in a variant entry must be equal to the entry's outcome. So this is great.
In the prompting sync today, Gustavo pushed back on the idea of allowing multiple rules with identical path patterns (as opposed to identical pattern variants), on the grounds that this creates confusion for the user about which of those rules has priority and which takes effect. We agreed that in some cases where there is partial overlap between multiple rules (that is, the rules render to one or more identical pattern variants and have the same outcome), it's easier to permit the complexity of the overlap than require the user to navigate modifying the rules to avoid conflict (and sometimes it's not possible to modify a rule without changing the overall semantics of the ruleset). For example:
In this case, the two rules overlap on the "allow read However, if we treat "identical path pattern" as a special case of "identical pattern variant", we can actually combine rules which have truly identical path patterns (so long as they have the same outcome). That is: if there exists a rule with the same path pattern and same outcome, but we have different permissions, union the existing permissions with the new permissions or tell the user "hey, you already have a rule with that exact path pattern, would how would you like to set its permissions". This sounds good, at least if we allow one "allow" rule and one "deny" rule for each path pattern, with different permissions for each, and no overlap. If we really want only one rule ever for a given path pattern, then we need to fundamentally change the way rule outcomes are handled, so a single rule can have different outcomes for different permissions. I like and can implement the first approach, I don't think we should change the way rules store outcomes without further discussion. I'm also wary of assuming any sort of guarantees about "identical" path patterns not having overlaps. I think we should really treat this as a special case, just for the convenience of users in the most common situations: if a user previously replied "allow read
In this example, it's trivially clear that these two path patterns render to an identical set of variants, but it's not in general possible to check this without rendering and storing that complete set of variants for both rules. (A closely-related problem, computing whether a pattern renders to duplicate variants and trying to deduplicate them in the tree before rendering, is discussed in #14319 and #14526.) We could potentially render each rule's complete set of variants, sort them lexicographically, and compare these, but I'd rather not go down that path if we can help it. So current approach to implement: when we reply or add a rule, check for an existing rule with an identical path pattern and the same outcome, and if one exists, add any new permissions to it. If there's a rule with identical path pattern but different outcome, and no overlapping permissions, it needs to coexist, since rules can't mix "allow" and "deny" for different permissions, and we'll never have both rules apply to the same request since the permissions are disjoint. Thinking more long term about this problem and how to clearly express to the user what is allowed and denied, and let the user easily change that: Someone today mentioned the idea of splitting up rules which cover multiple permissions into discrete rules for each permission, and if there's ever an overlap with another rule, it's trivial to adjust the path pattern of a rule to remove the identical pattern variant (or remove the rule entirely) with no side effects on other permissions. And as mentioned above, Gustavo discussed only ever having at most one rule for a given path pattern, and being able to set individual permissions as allowed or denied for that path pattern. It sounds to me like the way to fully disambiguate situations of rule overlap is to forego the idea of discrete rules where rule X allows r|w access for pattern variants a,b,c and might overlap with some other rule Y which allows r|x access for pattern variants c,d,e. Instead all we (and the users) care about is the final rule state:
Then, if a user wants to add or remove a permission for a particular pattern variant, they can do so without conflicts and without any side effects on other pattern variants or permissions. I would propose that whenever a user replies to a prompt or creates a new rule manually, the contents of that new rule are inserted into the ruleset, and if there's no conflict, the rulestate reflects the old state plus the new rule, but doesn't afterwards continue to distinguish the contents of that new rule as a discrete unit. If there's a conflict while inserting it, then it's easy to reply with exactly which pattern variant and permissions conflict, and the user can adjust accordingly. (I'm not sure if this improves the situation where the user is trying to reply with a carefully carved-out set of permissions and pattern variants, so perhaps a change to the format for replies/rules would be required here, or else the client should create multiple rules individually or send a reply containing multiple disjoint variant/permission combinations.) Regardless of whether we make a change like this, Innes and I discussed how storing the internal rule state in a trie would make computing precedence much simpler and faster, and make it much easier to reason about the state of the permissions granted. I think if we exposed a very similar structure of internal rules to the user in the Security Center, they'd be much better able to add/remove/adjust rules without conflict or confusion, and know exactly which rules apply to a given path, in clear precedence order in the tree depth, and also be able to reason about the rule state without needing to provide a particular path pattern (though a path can still match multiple parallel branches of the tree). The thought is then that if we have a trie of pattern variants for each permission which we use internally for path matching because of its simplicity, wouldn't it be nice to just expose that same tree of pattern variants to users directly, rather than giving them a list of potentially-overlapping rules to parse through? The tree needs to be separate for each permission internally, but we can overlay these trees for all permissions in the UI, and let users modify permissions for particular pattern variants, much like Gustavo proposed for path patterns, but without the potential for conflicts or overlaps. What do you think of this @sminez and @juanruitina? |
Changed milestone to 2.67, this needs more time. |
I agree with modifying the existing rule for identical path patterns. I can't help worrying mainly about this "special case", as it will probably be the most common. I need to ask now: To what extent is allowing braces in the custom path ( Regardless, it's becoming increasingly clear that we will need to show status or conflicts dynamically in the prompt, either for disabling permission checkboxes for which there are rules already (and this will be affected by which path the user has selected or provided) or giving user actionable info on conflicts (even if we only let them handle them in the Security Center). The user shouldn't need to wait to submit to get info on those conflicts. I would be very much in favour of prioritising this. As for how to visualise the tree in the Security Center, I would need to get a better understanding of rule conflicts first. But, keeping in mind default bias, it might be preferable to keep things simple there and perhaps flag conflicts subtly, as it's likely that most users will stick to default options. It might be a bit too soon to figure this out. The user already has the option to remove all rules, in case this is blocking them. (And I'll defer to you both on how this all should be handled/stored by snapd) |
Discussed this with @pedronis, we agreed on a similar approach which should meet the following goals:
So the big takeaways are:
|
Thanks for the update on this @olivercalder! The changes you and @pedronis are proposing here look good to me 👍 One thing I want to double check and clarify though: when you say the rule structure needs to change to accommodate different outcomes for different permissions, are you meaning the structure that we are currently pulling in the security center? Or just the internal representation within snapd? I would have thought that the client facing rule structure would be able to remain unchanged but maybe I'm missing something 🙂 |
Yes this includes the structure in the security center as well, since rules will support multiple different outcomes for different permissions in the same rule, so the security center needs to reflect that if it wants to display rules accurately. That's the tough part, as this is a breaking change. This is the current structure: 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"`
}
type Constraints struct {
PathPattern *patterns.PathPattern `json:"path-pattern,omitempty"`
Permissions []string `json:"permissions,omitempty"`
} This will be changed to be: 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"`
Lifespan prompting.LifespanType `json:"lifespan"`
Expiration time.Time `json:"expiration,omitempty"`
}
type Constraints struct {
PathPattern *patterns.PathPattern `json:"path-pattern,omitempty"`
Permissions map[string]OutcomeType `json:"permissions,omitempty"`
} The difference being the outcome is removed from the So the question is how best to do this. I think we could potentially change the name of the |
I think I'm not following or missing something here. My understanding was that we have (or are aiming to have) two representations of the rule state:
If that is correct (and maybe this is where I am wrong?) it is a lossless conversion from In the case of error reporting when rules collide, if you also tag each |
We had a quick live chat about this to get on the same page :) The distinction is that currently each rule has a single outcome and a list of permissions to which that outcome applies. But we want each rule to potentially have different outcomes for different permissions, all of which are associated with a single path pattern. If we have an existing The same thing can be done if the existing rule and new reply have the same path pattern but disjoint permissions, so long as they have the same outcome. If we have an existing If we have an existing The problem is if we have an existing So regarding your thoughts above:
We really have "three" representations of the rule state:
|
The remaining problem I see is how to deal with different lifespans. For example, if we have an existing rule which has
I think the best solution is to decouple the lifespan (and duration) from the rule and move it to per-permission as well. This adds slightly more complexity than moving the outcome to per-permission, but I think it's the only way to maintain correctness of rules without side effects, and while only having at most a single rule for each path pattern. So I think the 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"`
Permissions map[string]*PermEntry `json:"permissions"`
}
type PermEntry struct {
Outcome prompting.OutcomeType `json:"outcome"`
Lifespan prompting.LifespanType `json:"lifespan"`
Expiration time.Time `json:"expiration,omitempty"`
}
type Constraints struct {
// Constraints will vary by interface, hence the dedicated sub-struct...
// But is this actually helpful/necessary vs just having a PathPattern
// field directly in the Rule struct? It makes marshalling harder...
PathPattern *patterns.PathPattern `json:"path-pattern"`
} In this way, different permissions can have different lifespans. This does make rule expiration more difficult, as one needs to check through all the permissions to see if all are expired before knowing a rule as a whole is expired, but I think we can work out something reasonable here. |
As this PR is currently self-contained, accomplishes what it was initially set out to accomplish, and is a stepping stone to the one-rule-per-path-pattern world we'd like to live in, I'm going to leave it as is and open a follow-up PR with the work to make rules have unique path patterns. That change requires a deeper rework of rule internals. |
3966b44
to
fc2a992
Compare
fc2a992
to
bb21adc
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks, some suggestions to simplify things a bit
}) | ||
} | ||
} | ||
if len(conflicts) > 0 { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would it make sense to check this early in the fuction as well then?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The motivation for waiting until this point in the closure is so we can include every conflict with every variant. The RuleConflictError
wraps these in addRuleToTree
, so the error response has all the information for the client. I think if we checked any earlier, we would miss conflicts.
// changes will be discarded, so don't bother building the new | ||
// variant entry | ||
return | ||
} | ||
newEntry := variantEntry{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
setting this partially up could be done before the previous "if", same with filling newVariantEntries[variantStr]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I see, yes the existing logic is convoluted, relies on lots of checks and early returns. I've cleaned this up in the latest commit. Moved this bit up before the for loop, and consolidated the two for loops together with fewer unnecessary checks.
if existingEntry.Outcome != rule.Outcome { | ||
// We know all existing rule IDs were expired, else there would | ||
// have been a conflict | ||
return |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
then this path could be incorporated also in the previous if
type variantEntry struct { | ||
Variant patterns.PatternVariant | ||
RuleID prompting.IDType | ||
Outcome prompting.OutcomeType | ||
RuleIDs map[prompting.IDType]bool |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is not stored to disk, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct. The rule tree uses variantEntry
as the leaves, and this whole tree is in-memory only, used for efficient lookup and precedence decision (since precedence between two rules depends on the particular pattern variant which matched a requested path).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks, the code is cleaner/easier the follow now
} | ||
if existingEntry.Outcome == rule.Outcome { | ||
// Preserve non-expired rule which doesn't conflict | ||
newEntry.RuleIDs[id] = true |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
does existingEntry.RuleIDs
need to be updated someplace as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We replace existingEntry
with newEntry
in its entirety at the end if we don't hit any conflicts. By leaving existingEntry
unchanged, we can roll back and leave everything unchanged by simply not replacing existinglyEntry
with newEntry
.
Allow several rules which render to the same variant to coexist in the tree without conflict, so long as the outcome of all those overlapping rules is identical. This allows the client to reply with "allow read forever for /foo/bar" and then later say "allow read|write forever for /foo/bar" without the latter being treated as a rule conflict error. Clearly, the second rule is a superset of the first, and there's no intent-based reason that these two rules couldn't coexist, it was just an implementation detail that we previously only allowed a pattern variant to be associated with a single rule ID. Now, each pattern variant in the tree for a particular snap, interface, and permission can be associated with a set of rule IDs. Any non-expired rules in that set must have the same outcome. Any expired rules in the set are ignored (and removed when convenient). Signed-off-by: Oliver Calder <[email protected]>
Signed-off-by: Oliver Calder <[email protected]>
Signed-off-by: Oliver Calder <[email protected]>
Signed-off-by: Oliver Calder <[email protected]>
b9a6da2
to
3aa3632
Compare
Rebased on master to pull in test improvements |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
Failed tasks:
Failed prepare:
Failed restore:
|
Now that canonical#14538 has landed, rules may overlap as long as their outcomes do not conflict. As such, the download_file_defaults test case is no longer expected to fail. Signed-off-by: Oliver Calder <[email protected]>
* tests: add simple apparmor prompting integration tests Signed-off-by: Oliver Calder <[email protected]> * tests/lib/tools: correct tests.session exec usage statement Since the `exec` argument handler includes a `break` statement, all further arguments are treated as the command to run and potential arguments to that command. Thus, the `-u`, `-p`, and `--` arguments cannot occur after `exec`. This commit changes the usage statement to reflect this, since all existing tests use `tests.session [-u USER] [-p PID_FILE] exec <CMD>` rather than `tests.session exec [-u USER] [-p PID_FILE] -- <CMD>`. Since `--` is unused and cannot work with `exec` given the former's `break` statement, remove the `--` argument. Signed-off-by: Oliver Calder <[email protected]> * tests: fix apparmor prompting integration tests Signed-off-by: Oliver Calder <[email protected]> * tests: add shellcheck exceptions for apparmor prompting tests Signed-off-by: Oliver Calder <[email protected]> * tests: add download file tests for apparmor prompting Signed-off-by: Oliver Calder <[email protected]> * tests: fix apparmor prompting tests script ownership Signed-off-by: Oliver Calder <[email protected]> * tests: add apparmor prompting rule timespan tests Signed-off-by: Oliver Calder <[email protected]> * tests: fix rule timeout in timespan deny test for apparmor prompting Signed-off-by: Oliver Calder <[email protected]> * tests: add apparmor prompting tests for explicit rule conflict Signed-off-by: Oliver Calder <[email protected]> * tests: move prompting scripted client invocation to main task and reduce sleeps Signed-off-by: Oliver Calder <[email protected]> * tests: add prompting tests for writes actioned by other pid Signed-off-by: Oliver Calder <[email protected]> * tests: make prompt actioned by other pid tests not block on dir lock When creating a new file is blocked on a reply to a request prompt, the directory in which the file will be created is locked from other writes. Thus, we can't queue up multiple outstanding writes on files in the same directory. Instead, we must write files in different directories in order for this test to succeed. Signed-off-by: Oliver Calder <[email protected]> * tests: clarify comments around directory locking in prompting tests Signed-off-by: Oliver Calder <[email protected]> * tests: clarify apparmor prompting test comments and add debug rules Signed-off-by: Oliver Calder <[email protected]> * tests: add prompting tests for writing/reading existing files Signed-off-by: Oliver Calder <[email protected]> * tests: fix prompting tests which queue up creates and reply single Signed-off-by: Oliver Calder <[email protected]> * tests: address review comments on prompting integration tests Signed-off-by: Oliver Calder <[email protected]> * tests: fix unwanted shell expansion in apparmor prompting tests Signed-off-by: Oliver Calder <[email protected]> * tests: improve checks for snapd restart after enabling prompting Signed-off-by: Oliver Calder <[email protected]> * tests: prompting integration tests wait for particular client with test dir to terminate Signed-off-by: Oliver Calder <[email protected]> * tests: fix apparmor prompting pgrep, pkill, and NOMATCH usage Signed-off-by: Oliver Calder <[email protected]> * tests: fix bugs in prompting integration tests found by shellcheck Signed-off-by: Oliver Calder <[email protected]> * tests: fix prompting integration tests support check on older systems Signed-off-by: Oliver Calder <[email protected]> * tests: increase restart timeout after enabling apparmor prompting Signed-off-by: Oliver Calder <[email protected]> * tests: fix snapd restart check to actually re-check pid Signed-off-by: Oliver Calder <[email protected]> * tests: move apparmor prompting snap install client to execute with retry Signed-off-by: Oliver Calder <[email protected]> * tests: move apparmor prompting client snap install back to prepare Signed-off-by: Oliver Calder <[email protected]> * tests: adjust prompting download file tests now that rules may overlap Now that #14538 has landed, rules may overlap as long as their outcomes do not conflict. As such, the download_file_defaults test case is no longer expected to fail. Signed-off-by: Oliver Calder <[email protected]> * tests: fix prompting test case where client expects error Signed-off-by: Oliver Calder <[email protected]> --------- Signed-off-by: Oliver Calder <[email protected]>
Allow several rules which render to the same variant to coexist in the tree without conflict, so long as the outcome of all those overlapping rules is identical.
This allows the client to reply with "allow read forever for /foo/bar" and then later say "allow read|write forever for /foo/bar" without the latter being treated as a rule conflict error. Clearly, the second rule is a superset of the first, and there's no intent-based reason that these two rules couldn't coexist, it was just an implementation detail that we previously only allowed a pattern variant to be associated with a single rule ID.
Now, each pattern variant in the tree for a particular snap, interface, and permission can be associated with a set of rule IDs. Any non-expired rules in that set must have the same outcome. Any expired rules in the set are ignored (and removed when convenient).
CC @sminez and @juanruitina
This addresses the issue discussed here: canonical/desktop-security-center#74
This work is tracked internally by https://warthogs.atlassian.net/browse/SNAPDENG-32361