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

i/prompting: add package for prompting common types/functions #13849

Merged

Conversation

olivercalder
Copy link
Member

@olivercalder olivercalder commented Apr 18, 2024

This PR includes some type definitions and helper functions for apparmor prompting, originally from #13668, which do not relate to constraints or abstract permissions.

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

i/prompting: make `TimestampToTime()` return a local time, matching `time.Now()` and `time.Unix()`

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

i/prompting: make duration a string parsable by `time.ParseDuration()`

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

i/prompting: reorder functions

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

i/prompting: remove "app" from prompts and rules

AppArmor permissions are granted by interfaces according to plugs/slots
which may or may not differ between apps within a given snap. For the
home interface, for example, there is never an instance where one app
within a snap is granted a permission that another app in that same snap
is not. Thus, it does not make sense for a rule to apply to only a
single app within a snap.

Instead, the interface field should optionally include the name of the
plug or slot which granted the permissions, which should be received
from the kernel using message tagging. The result should be of the form:

`<interface name>[/<plug or slot>]`

As such, rules should no longer be associated with a particular app, and
in order to match, neither should request prompts. Adjustments are made
to the prompting API to remove the "app" parameter when querying rules.

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

i/prompting: add validation functions for expiration

Adds expiration-related analogues for `ValidateLifespanParseDuration`
and `ValidateConstraintsOutcomeLifespanDuration`.

Additionally, improves error messages by including the contents of the
invalid field in the message.

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

i/prompting: move prompting common types/functions to interfaces/prompting

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
@olivercalder olivercalder removed the Skip spread Indicate that spread job should not run label Apr 18, 2024
@olivercalder olivercalder reopened this Apr 18, 2024
@olivercalder olivercalder marked this pull request as ready for review April 18, 2024 16:45
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>
MiguelPires
MiguelPires previously approved these changes Apr 19, 2024
snap/info.go Outdated Show resolved Hide resolved
snap/info_test.go Outdated Show resolved Hide resolved
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
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.

bunch more comments

snap/info.go Outdated Show resolved Hide resolved
snap/info.go Outdated Show resolved Hide resolved
}

// ValidateOutcome returns nil if the given outcome is valid, otherwise an error.
func ValidateOutcome(outcome OutcomeType) error {
Copy link
Collaborator

Choose a reason for hiding this comment

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

this is a bit strange, shouldn't it be a parse function taking a string and returning the OutcomeType or an error?

Copy link
Collaborator

Choose a reason for hiding this comment

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

or maybe what you need is MarshalJSON UnmarshalJSON methods?

Copy link
Member Author

Choose a reason for hiding this comment

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

Since OutcomeType is an alias for string, the json {,un}marshaller works on OutcomeType directly, so potentially malformed outcomes are already OutcomeType variables which need to be validated, rather than strings. Theoretically, this could be built into custom {M,Unm}arshalJSON functions, but then there's no way to validate values when explicitly defined manually (i.e. myOutcome := OutcomeType("allow") instead of OutcomeAllow). There should probably never be a case where custom strings are cast to OutcomeTypes, so perhaps this negative isn't really relavant and a custom {,un}marshaller for OutcomeType would be ergonomic.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Hm if we don't care about defining new values of OutcomeType manually, then I think a trivial UnmarshalJSON would be preferred. You do not need MarshalJSON in such case.

// If the lifespan is LifespanTimespan LifespanTimespan, then expiration must
// be a string parsable as time.Duration with RFC3339 format. Otherwise, it must
// be empty. Returns an error if any of the above are invalid.
func ValidateLifespanExpiration(lifespan LifespanType, expiration string, currTime time.Time) error {
Copy link
Collaborator

Choose a reason for hiding this comment

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

same question

Copy link
Member Author

Choose a reason for hiding this comment

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

More strongly than for OutcomeType, I do think this needs to be an explicit standalone function (rather than a custom json {,un}marshaller), since it's important that we're able to pass in the current time as a variable. Additionally, this is about validating the compatibility of the expiration for the given lifespan, so I don't believe this can belong in the {,un}marshaller for the LifespanType. It could be in that of the containing struct (e.g. PromptReply and RequestRule), but then there is duplicate code in these places, so I'm not sure it makes sense.

interfaces/prompting/prompting.go Outdated Show resolved Hide resolved
Since `time.Time` values are marshalled as RFC3339Nano by default, there
is no reason to explicitly save timestamps and expirations as strings
and convert them to `time.Time` on the fly as necessary. Let the
marshaller do this automatically.

As expirations should only be populated when lifespan is "timespan",
expirations can be stored as `*time.Time`, which should be encoded as
`nil` when empty and an expiration timestamp in RFC3339Nano format when
non-empty.

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

Regarding the timestamp-related helpers: timestamps and expirations can be stored as time.Time instead of a string, so these can be removed -- see olivercalder#34

Copy link
Member Author

@olivercalder olivercalder left a comment

Choose a reason for hiding this comment

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

Comments after live review with @zyga and @bboozzoo -- thanks!

interfaces/prompting/prompting.go Outdated Show resolved Hide resolved
interfaces/prompting/prompting.go Outdated Show resolved Hide resolved
interfaces/prompting/prompting.go Outdated Show resolved Hide resolved
// should be valid. Otherwise, it must be empty. Returns an error if any of the
// above are invalid, otherwise computes the expiration time of the rule based
// on the current time and the given duration and returns it.
func ValidateLifespanParseDuration(lifespan LifespanType, duration string) (*time.Time, error) {
Copy link
Member Author

Choose a reason for hiding this comment

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

me: should probably take currTime as well, for consistency

interfaces/prompting/prompting.go Outdated Show resolved Hide resolved
interfaces/prompting/prompting.go Outdated Show resolved Hide resolved
// The timestamp is the same time, encoded as a string in time.RFC3339Nano.
func NewIDAndTimestamp() (id string, timestamp time.Time) {
now := time.Now()
nowUnix := uint64(now.UnixNano())
Copy link
Member Author

Choose a reason for hiding this comment

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

What happens when the time reports the same unixnano?

Copy link
Member Author

Choose a reason for hiding this comment

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

Just use a counter. Store with rules storage? And use with request prompts as well.

Or combine boot ID with monotonic boot time. (look for helper, reads from /proc)

CLOCK_MONOTONIC
A nonsettable system-wide clock that represents monotonic time since—as described by POSIX—"some unspecified point in the past". On Linux, that point corresponds to the number of seconds that the system has been running since it was booted.
The CLOCK_MONOTONIC clock is not affected by discontinuous jumps in the system time (e.g., if the system administrator manually changes the clock), but is affected by frequency adjustments.

Copy link
Member Author

Choose a reason for hiding this comment

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

According to the time package documentation (https://pkg.go.dev/time#hdr-Monotonic_Clocks), time.Now() automatically includes both monotonic and wall time:

If Times t and u both contain monotonic clock readings, the operations t.After(u), t.Before(u), t.Equal(u), t.Compare(u), and t.Sub(u) are carried out using the monotonic clock readings alone, ignoring the wall clock readings. If either t or u contains no monotonic clock reading, these operations fall back to using the wall clock readings.

Because the monotonic clock reading has no meaning outside the current process, the serialized forms generated by t.GobEncode, t.MarshalBinary, t.MarshalJSON, and t.MarshalText omit the monotonic clock reading, and t.Format provides no format for it. Similarly, the constructors time.Date, time.Parse, time.ParseInLocation, and time.Unix, as well as the unmarshalers t.GobDecode, t.UnmarshalBinary. t.UnmarshalJSON, and t.UnmarshalText always create times with no monotonic clock reading.

Thus, the monotonic time is only stripped when rules/prompts are read from disk. This occurs when snapd is restarted, including on system start. Unfortunately, this means time.Now() is not guaranteed to be unique between snapd restarts, so we likely need to include both boot ID and system monotonic time when creating rule/prompt IDs.

Copy link
Collaborator

Choose a reason for hiding this comment

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

where and what are this IDs used for ? in general snapd prefers to use sequential increasing IDs? at least for norification from the kernel don't we get them in a single goroutine? in that context using sequential IDs would be cheap, no?

Copy link
Collaborator

Choose a reason for hiding this comment

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

AFAIU the IDs are stored within the prompting namespace in notices and we need them to be unique across reboots too (@olivercalder please correct me if I'm wrong), so to keep them sequential we'd either need to store some state.

Copy link
Collaborator

@pedronis pedronis Apr 25, 2024

Choose a reason for hiding this comment

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

well, we could restart counting from the max of the pending ones when we restart

Copy link
Collaborator

Choose a reason for hiding this comment

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

yes, that's a good point

Copy link
Member Author

Choose a reason for hiding this comment

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

We could start from the max of the pending ones, though that would allow for the potential that IDs are reused for different rules, if a rule is removed, then snapd restarted. This isn't necessarily a problem, as we don't guarantee that IDs are never reused, but I'd prefer to keep them unique if possible.

I think storing a count as part of the rule storage would solve this problem, and it can be atomically incremented whenever a new rule is added. For prompt requests, which are not written to disk (because of listener/notify socket behavior, but that can be discussed in a later PR), we can just keep a count in memory and atomically increment it.

So, my opinion is that we should remove this function for now and add a NewID function when necessary in the requestrules and requestprompts packages.

snap/info.go Outdated Show resolved Hide resolved
Additionally, adjust related error messages to remove "invalid ...:"
prefix.

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Instead, use the existing `ParseSecurityTag` function and then call
`InstanceName` on the result.

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
There is not currently a good way to identify when a session ends when a
user has logged in via multiple methods (e.g. tty, ssh). Until it is
deemed to be necessary and a good implementation is developed, do not
include LifespanSession.

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
@olivercalder olivercalder force-pushed the prompting-common-types-helpers branch from 69a3fc7 to 4adc766 Compare April 24, 2024 20:18
@pedronis pedronis dismissed MiguelPires’s stale review April 25, 2024 07:10

this is still under heavy changes

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
IDs used for prompting (rules and prompt requests) must be unique across
reboots, and thus cannot rely on timestamps or even monotonic time. We
also want IDs to be sorted, so we cannot use boot ID along with
monotonic time either. Lastly, since we need IDs to be unique across
reboots, we can't simply use a counter based on the maximum of the
existing rule/prompt IDs. Instead, we should store the current maximum
ID in memory and on disk alongside the storage of rules and prompts.
This should be implemented in the respective forthcoming packages and
used at the former callsights of NewIDAndTimestamp.

Since IDs should no longer be tied to timestamps, we can simply use
`time.Now()` where the timestamps from `NewIDAndTimestamp` were formerly
used.

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
Comment on lines 96 to 124
func ValidateLifespanExpiration(lifespan LifespanType, expiration time.Time, currTime time.Time) error {
switch lifespan {
case LifespanForever, LifespanSingle:
if !expiration.IsZero() {
return fmt.Errorf(`expiration must be omitted when lifespan is %q, but received non-zero expiration: %q`, lifespan, expiration)
}
case LifespanTimespan:
if expiration.IsZero() {
return fmt.Errorf(`expiration must be non-zero when lifespan is %q, but received empty expiration`, lifespan)
}
if currTime.After(expiration) {
return fmt.Errorf("expiration time has already passed: %q", expiration)
}
default:
// Should not occur, since lifespan is validated when unmarshalled
return fmt.Errorf(`lifespan must be %q, %q, or %q`, LifespanForever, LifespanSingle, LifespanTimespan)
}
return nil
}

// ValidateLifespanParseDuration checks that the given lifespan is valid and
// that the given duration is valid for that lifespan.
//
// If the lifespan is LifespanTimespan, then duration must be a string parsable
// by time.ParseDuration(), representing the duration of time for which the rule
// should be valid. Otherwise, it must be empty. Returns an error if any of the
// above are invalid, otherwise computes the expiration time of the rule based
// on the current time and the given duration and returns it.
func ValidateLifespanParseDuration(lifespan LifespanType, duration string) (time.Time, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

if we keep them they should be methods on LifespanType I think

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 agree, thanks!

…espan

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

@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 with some error message tweaks

// ValidateExpiration checks that the given expiration is valid for the
// receiver lifespan.
//
// If the lifespan is LifespanTimespan LifespanTimespan, then expiration must
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// If the lifespan is LifespanTimespan LifespanTimespan, then expiration must
// If the lifespan is LifespanTimespan , then expiration must

}
parsedDuration, err := time.ParseDuration(duration)
if err != nil {
return expiration, fmt.Errorf(`error parsing duration string: %q`, duration)
Copy link
Collaborator

Choose a reason for hiding this comment

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

FWIW, errors usually follow the pattern of cannot do sth... ParseDuration error already includes the duration string, no need to repeat it again.

Suggested change
return expiration, fmt.Errorf(`error parsing duration string: %q`, duration)
return expiration, fmt.Errorf(`cannot parse duration: %w`, duration, err)

Copy link
Collaborator

Choose a reason for hiding this comment

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

that's correct, errors should try to be of the form "cannot ..." whenever possible

expiration = currTime.Add(parsedDuration)
default:
// Should not occur, since lifespan is validated when unmarshalled
return expiration, fmt.Errorf(`lifespan must be %q, %q, or %q`, LifespanForever, LifespanSingle, LifespanTimespan)
Copy link
Collaborator

Choose a reason for hiding this comment

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

we already know what the valid values are, so should this occur at runtime, it's actually nice to know what the invalid value is:

Suggested change
return expiration, fmt.Errorf(`lifespan must be %q, %q, or %q`, LifespanForever, LifespanSingle, LifespanTimespan)
return expiration, fmt.Errorf("internal error: invalid lifespan %q", lifespan)

}
default:
// Should not occur, since lifespan is validated when unmarshalled
return fmt.Errorf(`lifespan must be %q, %q, or %q`, LifespanForever, LifespanSingle, LifespanTimespan)
Copy link
Collaborator

Choose a reason for hiding this comment

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

same here about returning the invalid value rather than the valid ones (which we know already); should prob count as internal error too (as inputs from JSON go through unmarshalling and so would be checked anyway)

case LifespanForever, LifespanSingle, LifespanTimespan:
*lifespan = value
default:
return fmt.Errorf(`lifespan must be %q, %q, or %q`, LifespanForever, LifespanSingle, LifespanTimespan)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
return fmt.Errorf(`lifespan must be %q, %q, or %q`, LifespanForever, LifespanSingle, LifespanTimespan)
return fmt.Errorf(`lifespan must be %q, %q, or %q, not %q`, LifespanForever, LifespanSingle, LifespanTimespan, value)

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.

looks good, but couple of small comments about doc comments and agreeing with Maciej about being careful with error messages

"time"
)

type OutcomeType string
Copy link
Collaborator

Choose a reason for hiding this comment

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

this needs a doc string

}
}

type LifespanType string
Copy link
Collaborator

Choose a reason for hiding this comment

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

same

}
parsedDuration, err := time.ParseDuration(duration)
if err != nil {
return expiration, fmt.Errorf(`error parsing duration string: %q`, duration)
Copy link
Collaborator

Choose a reason for hiding this comment

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

that's correct, errors should try to be of the form "cannot ..." whenever possible

Signed-off-by: Oliver Calder <oliver.calder@canonical.com>
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.

thanks

@olivercalder olivercalder added the Squash-merge Please squash this PR when merging. label May 1, 2024
@Meulengracht Meulengracht merged commit 356d7c1 into snapcore:master May 1, 2024
45 of 48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Samuele review Needs a review from Samuele before it can land Squash-merge Please squash this PR when merging.
Projects
None yet
5 participants