Skip to content
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

many: support mixed outcomes for permissions in prompting constraints #14581

Merged

Conversation

olivercalder
Copy link
Member

@olivercalder olivercalder commented Oct 8, 2024

This PR is based on #14538, and is tracked internally by https://warthogs.atlassian.net/browse/SNAPDENG-32594. It addresses some of the problems discussed in that PR (such as #14538 (comment)), and more broadly in canonical/desktop-security-center#74. CC @sminez @juanruitina.

Let rule content constraints have heterogeneous outcomes and lifespans for different permissions in the constraints. As such, convert the list of permissions to a map from permission to permission entry, where the entry holds the outcome, lifespan, and duration/expiration for that particular permission, where previous those were global to the containing rule, rule contents, or patch contents.

However, the existing concept of replying "allow"/"deny" to a particular set of requested permissions is clear and simple. We want to keep outcome, lifespan, and duration as reply-scoped values, not permission-specific, when accepting prompt replies. So we need different types of constraints for prompt replies vs. rule contents.

The motivation behind this is so that we can have only a single rule for any given path pattern. We may have a situation where the user previously replied with "allow read /path/to/foo" and they're now prompted for write access, they need to be able to respond with "deny read /path/to/foo". If we only support a single outcome for any given rule, then we'd need two rules for the same path /path/to/foo. Thus, we need rules to support different outcomes for different permissions.

The same logic applies for lifetimes and expirations, though this adds additional complexity now that the concept of rule expiration is shifted to being permission-specific. We care about expired rules in two primary places: when loading rules from disk, we want to discard any expired rules, and when adding a new rule, we want to discard any expired permission entry for a rule which shares a pattern variant with the new rule. For cases where that expired permission entry had a conflicting outcome, we clearly can't have that, and we want to remove the expired permission entry from its containing rule as well, so as to avoid confusion for the user without them needing to check expiration timestamps. Even if the outcome of the expired entry matches that of the new rule's entry for the same permission, we still want to prune the expired permission from the old rule to avoid confusion. The complexity is around when a notice is recorded for a rule for which some permissions have expired. At the moment, the logic is that a notice is recorded in these cases:

  • when a rule is loaded from disk
    • data may be "removed": "expired" if all permissions are expired
  • when a rule is added
  • when a rule is patched
  • when a rule is removed (with data "removed": "removed")
  • when a rule is found to be expired when attempting to add a new rule

Notably, a notice is not recorded automatically when a permission entry expires. Nor is a notice recorded when a permission is found to be expired, so long as its associated rule still has at least one non-expired permission. Neither pruning an expired permission entry from the rule tree nor from the entry's containing rule results in a notice, even though technically the rule data has changed, since the expired permission has been erased. The rationale is that the semantics of the rule have not changed, since the expiration of that permission was part of the semantics of the rule.

Since durations are used when adding/patching a rule and expirations are used when retrieving a rule, in addition to the differences for prompt replies vs. rule contents, we now need several different variants of constraints:

  • promptConstraints:
    • path, requested permissions list, available permissions list
    • internal to requestprompts, unchanged
  • ReplyConstraints:
    • path pattern, list of permissions
    • containing PromptReply holds outcome/lifespan/expiration
    • unchanged from before, though under a new name
    • converted to a Constraints if reply warrants a new rule
  • Constraints:
    • path pattern, map from permission to outcome, lifespan, duration
    • used when adding rule to the rule DB
    • converted to RuleConstraints when the new rule is created
  • RuleConstraints:
    • path pattern, map from permisison to outcome, lifespan, expiration
    • used when retrieving rules from the rule DB
    • never used when POSTing to the API
  • PatchConstraints:
    • identical to Constraints, but with omitempty fields
    • converted to RuleConstraints when the patched rule is created

To support this, we define some new types, including {,Rule}PermissionMap and {,Rule}PermissionEntry. The latter of these is used in the leaves of the rule DB tree in place of the previous set of rule IDs of rules whose patterns render to a given pattern variant.

Whenever possible, logic surrounding constraints, permissions, and expiration is pushed down to methods on these new types, thus simplifying the logic of their callers.

@olivercalder olivercalder added the Needs Samuele review Needs a review from Samuele before it can land label Oct 8, 2024
@github-actions github-actions bot added the Needs Documentation -auto- Label automatically added which indicates the change needs documentation label Oct 8, 2024
@olivercalder
Copy link
Member Author

olivercalder commented Oct 8, 2024

TODO:

  • Add unit tests for new constraints types
  • Add requestrules unit test for partial rule expiration
  • Decide on handling of partial- and fully-expired rules when:
    • Getting rule by ID (currently no expiration checks)
    • Getting all rules (current discards fully expired rules, but does not prune expired permissions)
    • Adding rule which conflicts with expired rule permission (currently prunes expired rule permission and removes rule if all permissions are expired)

@olivercalder olivercalder force-pushed the prompting-mixed-outcomes-per-permission branch from 82e0611 to ac6c4e2 Compare October 8, 2024 04:05
@olivercalder olivercalder added this to the 2.67 milestone Oct 8, 2024
Copy link

codecov bot commented Oct 8, 2024

Codecov Report

Attention: Patch coverage is 95.50562% with 20 lines in your changes missing coverage. Please review.

Project coverage is 78.29%. Comparing base (24a0034) to head (3ad9783).
Report is 68 commits behind head on master.

Files with missing lines Patch % Lines
...erfaces/prompting/requestprompts/requestprompts.go 90.00% 7 Missing and 2 partials ⚠️
interfaces/prompting/constraints.go 96.80% 5 Missing and 2 partials ⚠️
interfaces/prompting/requestrules/requestrules.go 96.15% 3 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #14581      +/-   ##
==========================================
+ Coverage   78.20%   78.29%   +0.09%     
==========================================
  Files        1151     1158       +7     
  Lines      151396   152573    +1177     
==========================================
+ Hits       118402   119463    +1061     
- Misses      25662    25745      +83     
- Partials     7332     7365      +33     
Flag Coverage Δ
unittests 78.29% <95.50%> (+0.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@olivercalder olivercalder force-pushed the prompting-mixed-outcomes-per-permission branch from ac6c4e2 to a191514 Compare October 16, 2024 19:47
@olivercalder olivercalder removed this from the 2.67 milestone Oct 21, 2024
olivercalder added a commit to olivercalder/snapd that referenced this pull request Oct 21, 2024
Now that canonical#14581 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 <oliver.calder@canonical.com>
@pedronis
Copy link
Collaborator

@olivercalder this needs a rebase now?

@olivercalder olivercalder force-pushed the prompting-mixed-outcomes-per-permission branch from a191514 to b2d5acc Compare November 7, 2024 06:23
@olivercalder olivercalder reopened this Nov 7, 2024
Copy link
Collaborator

@pedronis pedronis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did a pass on about half of this, some initial questions/comments

// 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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't constraints be used more through the function? and if not, why?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Match and ContainPermissions methods could be moved to the Constraints type instead of the ReplyConstraints type, but they're really about validating that the reply is well-formed, which is specific to ReplyConstraints rather than Constraints. We don't in general have a reason to check whether Constraints match a particular path or set of permissions --- that is the role of RuleConstraints. And further, I don't think it makes as much sense to check whether a rule has an entry for each permission in the list, since those entries could have mixed outcomes which wouldn't have come from a single reply, since replies always have a single outcome.

Basically, my motivation is about keeping the methods about validating replies (ToConstraints, Match, and ContainsPermissions) to ReplyConstraints, so there's no risk of accidentally mis-using them on Constraints in other situations. E.g. one never wants to match an incoming request against Constraints, as incoming requests should only be matched against RuleConstraints, and Constraints must always be converted to RuleConstraints. ReplyConstraints may be matched against existing requests to make sure they satisfy everything which was requested.

As for why replyConstraints.ToConstraints occurs before replyConstraints.Match and replyConstraints.ContainPermissions, the former validates that the reply is well-formed in the basic sense, while the latter two checks that it's semantically valid by satisfying the original request.

Does this address your question? Or is there something else I'm missing?

Comment on lines -175 to -177
if currTime.After(expiration) {
return fmt.Errorf("%w: %q", prompting_errors.ErrRuleExpirationInThePast, expiration)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this just a simplicication?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that rules can have mixed outcomes/lifespans/expirations, it may be the case that (e.g. when reading from disk) one permission has expired and another one has not. Rather than throwing an error when an expired permission is seen, it instead is removed from the rule at a later step, and any non-expired permissions remain.

In particular: ValidateExpiration is only called once, from within RulePermissionEntry.validate(), which is in turn only called once, in RulePermissionMap.validateForInterface(). Before calling entry.validate(), validateForInterface first checks entry.Expired(), and if the permission entry has expired, the permission is removed from the permission map at the end of the function, after ensuring that no other errors occurred.

So not quite a simplification, it's a change in the distinction between expired rules and invalid rules now that rules can be "partially expired" but still valid.

interfaces/prompting/errors/errors.go Outdated Show resolved Hide resolved
modified := prompt.Constraints.subtractPermissions(constraints.Permissions)
if !modified {
// No permission was matched
// Matched, so at least one permission was satisfied
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand the comment vs the code, matched doesn't meant that all outcomes are not deny? it seems the comment needs to be clarified/expanded

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matched means the path pattern of the rule matched the path of the prompt, and at least one permission from the rule matched at least one permission from the prompt. Whether all or just some of the permissions were matched, and whether each was allowed or denied, is what the other return values of matched, satisfied, denied, err := prompt.Constraints.applyRuleConstraints(constraints) indicates.

But I agree this is rather confusing. matched, satisfied, and denied are not the same types, and there is some implication between each.

I think the complexity comes down to the interaction between the way buildResponse, applyRuleConstraints, and their callers interact. A lot of it is because of the old way these used to work, which is no longer the case, so they can be simplified. I'll work on that.

Copy link
Collaborator

@pedronis pedronis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

couple more comments. I haven't reviewed requestrules.go changes yet, probably would be good to improve the rest first

interfaces/prompting/constraints.go Outdated Show resolved Hide resolved
interfaces/prompting/constraints.go Show resolved Hide resolved
@olivercalder olivercalder force-pushed the prompting-mixed-outcomes-per-permission branch from 663ab69 to 46b5c71 Compare November 14, 2024 04:44
@olivercalder
Copy link
Member Author

We need to be careful to support both the old and new rule formats during a transition period. The internals should use the new system, but we'll need to map the old format to the new structure, and provide a means of working in the old format over the API.

@olivercalder
Copy link
Member Author

I'd definitely appreciate some feedback from the Desktop team about how best to handle backwards compatibility for the client :)

CC @sminez @d-loose

@olivercalder
Copy link
Member Author

olivercalder commented Dec 2, 2024

As discussed with Desktop, the plan is for the snapd.dart to replace the explicit constraints format (currently specific to the home interface) with a generic constraints json blob. Then it's up to the client to parse the json blob and handle it according to the interface specified in the rule.

Regarding backwards compatibility, since old-format rules can be converted to new-format rules, the plan is for clients of snapd to map from old-format to new-format and pass that on to downstream clients, so they can immediately begin working with the new format. I believe snapd.dart will be responsible for doing this mapping before passing the rules on to the security center, but I want to check with @sminez on this to confirm.

As for compatibility on the snapd side, since there are currently no clients which send rule contents directly to snapd (e.g. for creating/patching rules directly, as opposed to implicitly creating rules via prompt replies), we aren't concerned with snapd accepting old-format rule contents over the API. Any client which wishes to POST rule contents to snapd should use the new format, as we expect this PR to be merged and snapd to be using the new format well before any clients will wish to do so.

The spec for the changes to the rule format can be found here: https://docs.google.com/document/d/19m27d-YK9TdGCucZyjDzHYsQqICE2RE2yPia7sTg7Ls/edit

@olivercalder
Copy link
Member Author

To clarify a bit: the only change to snapd.dart is that it will pass constraints as a json blob instead of a struct. Additionally, the rule-level "outcome", "lifespan", and "duration" fields will be marked as deprecated, and are preserved (and passed to the client) as a means of handling old-format rules from snapd. Once snapd acts with new-format rules, these fields will be omitted, and the outcome/lifespan/expiration will be included in the values of the permission map, as detailed in this PR and the spec linked above (https://docs.google.com/document/d/19m27d-YK9TdGCucZyjDzHYsQqICE2RE2yPia7sTg7Ls/edit).

@olivercalder olivercalder force-pushed the prompting-mixed-outcomes-per-permission branch from 46b5c71 to 176d934 Compare December 3, 2024 23:54
Copy link
Collaborator

@pedronis pedronis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a comment and also this has conflicts now

func (c *Constraints) ContainPermissions(permissions []string) bool {
// ContainPermissions returns true if the permission list in the constraints
// includes every one of the given permissions.
func (c *ReplyConstraints) ContainPermissions(permissions []string) bool {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iiuc ReplyContraints is not really validated unless via ToConstraints which makes me think that we really want ReplyContraints to not have any methods except the conversion and move the methods even if annoying to Constraints. That should force changing some of the code I'm right now slightly uncomfortable with.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that makes sense: ReplyConstraints.ContainPermissions and ReplyConstraints.Match can be moved to methods on Constraints instead.

The rationale for having those methods on ReplyConstraints instead of Constraints is that one should never call such methods on any constraints except ones which are received as part of a reply. But we can add a comment to that effect instead.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were also right about moving responseForInterfaceConstraintsOutcome in #14390, it's making the rebase much more difficult...

@olivercalder olivercalder force-pushed the prompting-mixed-outcomes-per-permission branch from 176d934 to 0576336 Compare December 4, 2024 19:40
Copy link
Collaborator

@pedronis pedronis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

couple of naming comments

interfaces/prompting/constraints.go Show resolved Hide resolved
// 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 PatchConstraints struct {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

given that the doc comment itself says: hold partial rule contents

I'm not against renaming this to RuleConstraintsPatch

Copy link
Collaborator

@pedronis pedronis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you

Copy link
Contributor

@bboozzoo bboozzoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

PathPattern *patterns.PathPattern `json:"path-pattern,omitempty"`
Permissions []string `json:"permissions,omitempty"`
PathPattern *patterns.PathPattern `json:"path-pattern"`
Permissions PermissionMap `json:"permissions"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changed from []string to map and the struct is leaking to the API. I'm assuming there's an agreement that as long as the feature is experimental we're free to make such changes?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The desktop-security-center is the only supported client at the moment, and it will accept either the old or the new format and always map the result to the new format, since the new format can express a proper superset of what the old format can express. Because the feature is experimental and the API is not yet officially documented, we feel comfortable making this breaking change, which the only "official" client already handles correctly.

Let rule content constraints have heterogeneous outcomes and lifespans
for different permissions in the constraints. As such, convert the list
of permissions to a map from permission to permission entry, where the
entry holds the outcome, lifespan, and duration/expiration for that
particular permission, where previous those were global to the
containing rule, rule contents, or patch contents.

However, the existing concept of replying "allow"/"deny" to a particular
set of requested permisisons is clear and simple. We want to keep
outcome, lifespan, and duration as reply-scoped values, not
permission-specific, when accepting prompt replies. So we need different
types of constraints for prompt replies vs. rule contents.

The motivation behind this is so that we can have only a single rule for
any given path pattern. We may have a situation where the user
previously replied with "allow read `/path/to/foo`" and they're now
prompted for write access, they need to be able to respond with "deny
read `/path/to/foo`". If we only support a single outcome for any given
rule, then we'd need two rules for the same path `/path/to/foo`. Thus,
we need rules to support different outcomes for different permissions.

The same logic applies for lifetimes and expirations, though this adds
additional complexity now that the concept of rule expiration is shifted
to being permission-specific. We care about expired rules in two primary
places: when loading rules from disk, we want to discard any expired
rules, and when adding a new rule, we want to discard any expired
permisison entry for a rule which shares a pattern variant with the new
rule. For cases where that expired permission entry had a conflicting
outcome, we clearly can't have that, and we want to remove the expired
permission entry from its containing rule as well, so as to avoid
confusion for the user without them needing to check expiration
timestamps. Even if the outcome of the expired entry matches that of the
new rule's entry for the same permission, we still want to prune the
expired permission from the old rule to avoid confusion. The complexity
is around when a notice is recorded for a rule for which some
permissions have expired. At the moment, the logic is that a notice is
recorded in these cases:

- when a rule is loaded from disk
    - data may be `"removed": "expired"` if all permissions are expired
- when a rule is added
- when a rule is patched
- when a rule is removed (with data `"removed": "removed"`)
- when a rule is found to be expired when attempting to add a new rule

Notably, a notice is not recorded automatically when a permission entry
expires. Nor is a notice recorded when a permission is found to be
expired, so long as its associated rule still has at least one
non-expired permission. Neither pruning an expired permission entry from
the rule tree nor from the entry's containing rule results in a notice,
even though technically the rule data has changed, since the expired
permission has been erased. The rationale is that the semantics of the
rule have not changed, since the expiration of that permission was part
of the semantics of the rule.

Since durations are used when adding/patching a rule and expirations are
used when retrieving a rule, in addition to the differences for prompt
replies vs. rule contents, we now need several different variants of
constraints:
- `promptConstraints`:
    - path, requested permissions list, available permissions list
    - internal to `requestprompts`, unchanged
- `ReplyConstraints`:
    - path pattern, list of permissions
    - containing `PromptReply` holds outcome/lifespan/expiration
    - unchanged from before, though under a new name
    - converted to a `Constraints` if reply warrants a new rule
- `Constraints`:
    - path pattern, map from permission to outcome, lifespan, duration
    - used when adding rule to the rule DB
    - converted to `RuleConstraints` when the new rule is created
- `RuleConstraints`:
    - path pattern, map from permisison to outcome, lifespan, expiration
    - used when retrieving rules from the rule DB
    - never used when POSTing to the API
- `PatchConstraints`:
    - identical to `Constraints`, but with omitempty fields
    - converted to `RuleConstraints` when the patched rule is created

To support this, we define some new types, including `{,Rule}PermissionMap`
and `{,Rule}PermissionEntry`. The latter of these is used in the leaves
of the rule DB tree in place of the previous set of rule IDs of rules
whose patterns render to a given pattern variant.

Whenever possible, logic surrounding constraints, permissions, and
expiration is pushed down to methods on these new types, thus
simplifiying the logic of their callers.

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
…ests

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
…ound handling new rules

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
…omes

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
…ns` to `Constraints`

Since validation of `ReplyConstraints` occurs via the `ToConstraints`,
we should not have other methods on `ReplyConstraints`. Instead, the
`Match` and `ContainPermissions` methods are moved to `Constraints`,
intended to be valled just after the incoming `ReplyConstraints` have
been validated and converted into `Constraints`.

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Previously, if a rule entry was expired, we'd discard it before
validation, to avoid the error which wasn't particularly relevant, since
the entry was already expired. And that validation is only called during
initial loading of rules. But if it's invalid, perhaps the expiration is
malformed as well. So validate first.

Similarly, if a client asks to patch a rule by removing a permission
which isn't valid, rather than do nothing, return an error that the
permission they requested to remove was invalid. Note that this is
different from the permission not existing in the rule: removing a valid
permission from a rule is idempotent, but asking to remove an invalid
permission (which snapd doesn't allow to exist in a rule) is an error.

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
…not fully expired

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
…ted imports

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
@olivercalder olivercalder force-pushed the prompting-mixed-outcomes-per-permission branch from 3df76c4 to ffb6c9c Compare December 17, 2024 15:54
@olivercalder olivercalder added this to the 2.68 milestone Dec 17, 2024
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
…es format

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
@olivercalder
Copy link
Member Author

Test failures:

  • openstack:debian-12-64:tests/completion/ -- dpkg problem, unrelated
  • openstack:opensuse -- openstack problems, unrelated
  • google:ubuntu-16.04-64:tests/main/degraded -- unrelated
  • google:ubuntu-20.04-64:tests/unit/go:gcc -- unrelated, fix in the works by Katie
  • google-arm:ubuntu-core-22-arm-64:tests/core/snapd-refresh-undo -- unrelated, fix in the works by Katie

@ndyer ndyer merged commit 146e31f into canonical:master Dec 18, 2024
54 of 59 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Documentation -auto- Label automatically added which indicates the change needs documentation Needs Samuele review Needs a review from Samuele before it can land
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants