Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/FluentCMS.Core/Authorization/AuthorizationEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public async ValueTask<WritableFields> WritableFieldsAsync(

foreach (var grant in permissions.GrantsOf(type, operation, target))
{
if (operation != ContentOperation.Create && grant.Constraints.RowRule is { } name)
if ((operation != ContentOperation.Create || entry is not null) && grant.Constraints.RowRule is { } name)
{
if (!matched.TryGetValue(name, out var matches))
{
Expand All @@ -113,13 +113,15 @@ public async ValueTask<WritableFields> WritableFieldsAsync(
}
}

if (grant.Constraints.Fields is null)
var reachesShared = !type.Localized || grant.ReachesEveryLocale(target);
if (grant.Constraints.Fields is null && reachesShared)
{
return WritableFields.All;
}

allowed = true;
fields.UnionWith(grant.Constraints.Fields);
var names = grant.Constraints.Fields ?? type.Fields.Select(field => field.Name);
fields.UnionWith(reachesShared ? names : names.Where(name => type.FindField(name) is not { Localizable: false }));
}

return allowed ? new WritableFields(true, fields) : WritableFields.None;
Expand All @@ -141,7 +143,7 @@ private async Task<bool> IsInSiteAsync(SchemaType type, ContentOperation operati
private async Task<bool> MatchesAsync(SchemaType type, ContentOperation operation, Guid entry, AccessTarget target, QueryStatus status, ContentFilter rule, CancellationToken cancellationToken)
{
var state = operation == ContentOperation.Read && status == QueryStatus.Published ? ContentState.Published : ContentState.Draft;
var scope = new QueryScope(database, schema, dialect, new ContentQueryAccess.RuleOnly(type, rule), options, state, target.Locale ?? "", target.Site);
var scope = new QueryScope(database, schema, dialect, new ContentQueryAccess.RuleOnly(type, rule), options, state, target.RowLocale ?? target.Locale ?? "", target.Site);

var row = Expression.Parameter(typeof(QueryRow), "row");
var condition = new FilterCompiler(scope).Where(type, null, row)!;
Expand Down
28 changes: 23 additions & 5 deletions src/FluentCMS.Core/Authorization/EffectivePermissions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
namespace FluentCMS.Core.Authorization;

// Where an operation applies. A null site is every site, so only a grant with no site limit holds for it; an empty
// locale is no locale, so only a grant with no locale limit holds for it.
internal readonly record struct AccessTarget(SiteId? Site, string? Locale);
// locale is no locale, so only a grant with no locale limit holds for it. RowLocale is the row a row rule reads when
// that is not Locale's: a write that adds a locale to an entry has no row of its own there yet. EntryLocales is every
// locale the entry has or is about to have, for the fields they share; null means Locale alone.
internal readonly record struct AccessTarget(SiteId? Site, string? Locale, string? RowLocale = null, IReadOnlyCollection<string>? EntryLocales = null);

// One row of cms_role_permission, with the site of its role: a role of one site grants nothing in another.
internal sealed record EffectiveGrant(string Pattern, PermissionConstraints Constraints, Guid? RoleSite)
Expand All @@ -17,6 +19,16 @@ public bool Applies(string permission, AccessTarget target) =>
&& (RoleSite is null || target.Site?.Value == RoleSite)
&& (Constraints.Sites is null || (target.Site is { } site && Constraints.Sites.Contains(site)))
&& (Constraints.Locales is null || (!string.IsNullOrEmpty(target.Locale) && Constraints.Locales.Contains(target.Locale, StringComparer.OrdinalIgnoreCase)));

// A global entry is shared by every site, so what changes it must come from a grant that no site limits.
public bool IsInstanceWide => RoleSite is null && Constraints.Sites is null;

// A field that is not localizable has one value for all the locales of an entry, so a grant limited to some of them
// may change it only when that covers them all.
public bool ReachesEveryLocale(AccessTarget target) =>
Constraints.Locales is not { } locales
|| target.EntryLocales is null
|| target.EntryLocales.All(locale => locales.Contains(locale, StringComparer.OrdinalIgnoreCase));
}

// The answers of spec 2.3 for one principal: the union of its roles' grants, narrowed by its token's scopes. Scopes
Expand Down Expand Up @@ -45,7 +57,7 @@ public bool Allows(string permission, AccessTarget target)
}

public bool Allows(SchemaType type, ContentOperation operation, AccessTarget target) =>
Allows(CorePermissions.Content(type.Name, operation), target);
IsSystem || GrantsOf(type, operation, target).Count > 0;

// The roles must allow reading the type. On top of that a Delivery principal needs a preview key or the draft
// scope, and a managing one is bounded by its roles alone (spec 4.8).
Expand All @@ -57,7 +69,13 @@ public bool CanReadDraft(SchemaType type, AccessTarget target) =>
public IReadOnlyList<EffectiveGrant> GrantsOf(SchemaType type, ContentOperation operation, AccessTarget target)
{
var permission = CorePermissions.Content(type.Name, operation);
return ScopeAllows(permission) ? [.. grants.Where(grant => grant.Applies(permission, target))] : [];
return ScopeAllows(permission) ? [.. Applying(type, operation, permission, target)] : [];
}

private IEnumerable<EffectiveGrant> Applying(SchemaType type, ContentOperation operation, string permission, AccessTarget target)
{
var writesEverySite = type.Scope == ContentScope.Global && operation != ContentOperation.Read;
return grants.Where(grant => grant.Applies(permission, target) && (!writesEverySite || grant.IsInstanceWide));
}

// Null when every field is readable. A response has one set of fields for all its rows, and rows are the union of
Expand Down Expand Up @@ -142,7 +160,7 @@ public bool CanReadField(SchemaType type, string field, AccessTarget target)
}

SortedSet<string> names = new(StringComparer.Ordinal);
foreach (var grant in grants.Where(grant => grant.Applies(permission, target)))
foreach (var grant in Applying(type, operation, permission, target))
{
if (grant.Constraints.RowRule is not { } name)
{
Expand Down
44 changes: 42 additions & 2 deletions src/FluentCMS.Core/Authorization/FieldAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public void RejectUnwritable(ContentDocument changes, SchemaType type, WritableF
}
else if (SystemField.Find(key) is null)
{
if (type.FindField(key) is not { Hidden: false } field || !writable.Includes(key))
if (type.FindField(key) is not { Hidden: false, ReadOnly: false } field || !writable.Includes(key))
{
Refuse(errors, key, type);
}
Expand All @@ -69,6 +69,46 @@ public void RejectUnwritable(ContentDocument changes, SchemaType type, WritableF
}
}

// Whether a value of a field names nothing inside it that no client may write, whoever is asking.
public bool IsClean(SchemaField field, object? value, SchemaType type)
{
ArgumentNullException.ThrowIfNull(field);
ArgumentNullException.ThrowIfNull(type);

List<ValidationError> errors = [];
CheckInside(errors, field.Name, value, field, type);
return errors.Count == 0;
}

// What a before hook changed is held to the same grants as the client it runs for, so that a hook cannot be the way
// to a field the principal may not write. A hidden or read-only field is outside this: no client reaches it, and a
// hook is what fills it. Fields that are not there at all never get this far: the canonicalizer refuses them.
public void RejectHookChanges(IEnumerable<string> changed, SchemaType type, WritableFields writable)
{
ArgumentNullException.ThrowIfNull(changed);
ArgumentNullException.ThrowIfNull(type);
ArgumentNullException.ThrowIfNull(writable);

if (permissions.IsSystem)
{
return;
}

List<ValidationError> errors = [];
foreach (var name in changed)
{
if (type.FindField(name) is { Hidden: false, ReadOnly: false } && !writable.Includes(name))
{
Refuse(errors, name, type);
}
}

if (errors.Count > 0)
{
throw new ValidationException(errors);
}
}

private void OmitInEntry(ContentDocument document, SchemaType type, AccessTarget target, bool isTarget)
{
foreach (var key in document.Keys.ToList())
Expand Down Expand Up @@ -220,7 +260,7 @@ private void CheckEntries(List<ValidationError> errors, string path, IEnumerable
continue;
}

if (owner.FindField(key) is { Hidden: false } inner)
if (owner.FindField(key) is { Hidden: false, ReadOnly: false } inner)
{
CheckInside(errors, $"{path}.{key}", value, inner, root);
}
Expand Down
4 changes: 2 additions & 2 deletions src/FluentCMS.Core/Authorization/IAuthorizationEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ ValueTask<bool> CanAccessEntryAsync(
CancellationToken cancellationToken = default);

// Which fields a write may touch: a field counts when a grant that allows it also matches the entry. A create has
// no entry yet, so a row rule on its grant cannot hold anything back; any other write without an entry gets only
// what grants with no row rule allow.
// no entry at first, so a row rule on its grant holds nothing back until it is asked again with the new entry; any
// other write without an entry gets only what grants with no row rule allow.
ValueTask<WritableFields> WritableFieldsAsync(
OperationContext context,
SchemaType type,
Expand Down
Loading
Loading