diff --git a/edgraph/server.go b/edgraph/server.go index 4e815587aa5..7e6bb529e72 100644 --- a/edgraph/server.go +++ b/edgraph/server.go @@ -286,8 +286,12 @@ func parseSchemaFromAlterOperation(ctx context.Context, sch string) ( // there are pre-defined predicates (subset of reserved predicates), and for them we allow // the schema update to go through if the update is equal to the existing one. // So, here we check if the predicate is reserved but not pre-defined to block users from - // creating predicates in reserved namespace. - if x.IsReservedPredicate(update.Predicate) && !x.IsPreDefinedPredicate(update.Predicate) { + // creating predicates in reserved namespace. A predicate owned by a registered + // ReservedNamespace (see x.RegisterReservedNamespace) is allowed through, so a plugin can + // create its own predicates under `dgraph.` at runtime. + if x.IsReservedPredicate(update.Predicate) && + !x.IsPreDefinedPredicate(update.Predicate) && + !x.IsRegisteredReservedPredicate(update.Predicate) { return nil, errors.Errorf("Can't alter predicate `%s` as it is prefixed with `dgraph.`"+ " which is reserved as the namespace for dgraph's internal types/predicates.", x.ParseAttr(update.Predicate)) @@ -310,7 +314,10 @@ func parseSchemaFromAlterOperation(ctx context.Context, sch string) ( // Users are not allowed to create types in reserved namespace. But, there are pre-defined // types for which the update should go through if the update is equal to the existing one. - if x.IsReservedType(typ.TypeName) && !x.IsPreDefinedType(typ.TypeName) { + // A type owned by a registered ReservedNamespace is allowed through like its predicate + // counterparts, so a plugin can declare its own types under `dgraph.` at runtime. + if x.IsReservedType(typ.TypeName) && !x.IsPreDefinedType(typ.TypeName) && + !x.IsRegisteredReservedType(typ.TypeName) { return nil, errors.Errorf("Can't alter type `%s` as it is prefixed with `dgraph.` "+ "which is reserved as the namespace for dgraph's internal types/predicates.", x.ParseAttr(typ.TypeName)) @@ -1706,7 +1713,7 @@ func parseRequest(ctx context.Context, qc *queryContext) error { // parsing mutations qc.gmuList = make([]*dql.Mutation, 0, len(qc.req.Mutations)) for _, mu := range qc.req.Mutations { - gmu, err := ParseMutationObject(mu, qc.graphql) + gmu, err := ParseMutationObject(ctx, mu) if err != nil { return err } @@ -2181,7 +2188,7 @@ func hasPoormansAuth(ctx context.Context) error { // api.Mutation#SetJson, api.Mutation#SetNquads and api.Mutation#Set are consolidated into the // dql.Mutation.Set field. Similarly the 3 fields api.Mutation#DeleteJson, api.Mutation#DelNquads // and api.Mutation#Del are merged into the dql.Mutation#Del field. -func ParseMutationObject(mu *api.Mutation, isGraphql bool) (*dql.Mutation, error) { +func ParseMutationObject(ctx context.Context, mu *api.Mutation) (*dql.Mutation, error) { res := &dql.Mutation{Cond: mu.Cond} if len(mu.SetJson) > 0 { @@ -2225,7 +2232,7 @@ func ParseMutationObject(mu *api.Mutation, isGraphql bool) (*dql.Mutation, error return nil, err } - if err := validateNQuads(res.Set, res.Del, isGraphql); err != nil { + if err := validateNQuads(res.Set, res.Del, newReservedPredicateGuard(ctx)); err != nil { return nil, err } return res, nil @@ -2252,16 +2259,38 @@ func validateAndConvertFacets(nquads []*api.NQuad) error { return nil } -// validateForOtherReserved validate nquads for other reserved predicates -func validateForOtherReserved(nq *api.NQuad, isGraphql bool) error { - // Check whether the incoming predicate is other reserved predicate. - if !isGraphql && x.IsOtherReservedPredicate(nq.Predicate) { - return errors.Errorf("Cannot mutate graphql reserved predicate %s", nq.Predicate) +// reservedPredicateGuard reports an error if nq mutates the value of a reserved +// predicate the current request is not permitted to write. It is built once per +// request from the request context; see newReservedPredicateGuard. +type reservedPredicateGuard func(nq *api.NQuad) error + +// newReservedPredicateGuard builds the per-request reserved-value guard. The +// GraphQL admin path (IsGraphql) owns the dgraph.graphql.* predicates. A +// registered ReservedNamespace may additionally value-lock predicates to its +// own trusted writer (see x.RegisterReservedNamespace): such a predicate may be +// written only when the request context carries the namespace's TrustMarker. +// Every other caller is blocked. +func newReservedPredicateGuard(ctx context.Context) reservedPredicateGuard { + isGraphql, _ := ctx.Value(IsGraphql).(bool) + return func(nq *api.NQuad) error { + if !isGraphql && x.IsOtherReservedPredicate(nq.Predicate) { + return errors.Errorf("Cannot mutate graphql reserved predicate %s", nq.Predicate) + } + if marker, locked := x.ReservedPredicateValueLock(nq.Predicate); locked { + trusted := false + if marker != nil { + trusted, _ = ctx.Value(marker).(bool) + } + if !trusted { + return errors.Errorf("Cannot mutate reserved predicate %s outside its "+ + "owning service", nq.Predicate) + } + } + return nil } - return nil } -func validateNQuads(set, del []*api.NQuad, isGraphql bool) error { +func validateNQuads(set, del []*api.NQuad, guardReserved reservedPredicateGuard) error { for _, nq := range set { if err := validatePredName(nq.Predicate); err != nil { return err @@ -2276,7 +2305,7 @@ func validateNQuads(set, del []*api.NQuad, isGraphql bool) error { if err := validateKeys(nq); err != nil { return errors.Wrapf(err, "key error: %+v", nq) } - if err := validateForOtherReserved(nq, isGraphql); err != nil { + if err := guardReserved(nq); err != nil { return err } } @@ -2291,7 +2320,7 @@ func validateNQuads(set, del []*api.NQuad, isGraphql bool) error { if nq.Subject == x.Star || (nq.Predicate == x.Star && !ostar) { return errors.Errorf("Only valid wildcard delete patterns are 'S * *' and 'S P *': %v", nq) } - if err := validateForOtherReserved(nq, isGraphql); err != nil { + if err := guardReserved(nq); err != nil { return err } // NOTE: we dont validateKeys() with delete to let users fix existing mistakes diff --git a/worker/proposal.go b/worker/proposal.go index a7761bee175..2562b0bb929 100644 --- a/worker/proposal.go +++ b/worker/proposal.go @@ -171,8 +171,10 @@ func (n *node) proposeAndWait(ctx context.Context, proposal *pb.Proposal) (perr su, ok := schema.State().Get(ctx, edge.Attr) if !ok { // We don't allow mutations for reserved predicates if the schema for them doesn't - // already exist. - if x.IsReservedPredicate(edge.Attr) { + // already exist. A predicate owned by a registered ReservedNamespace + // (see x.RegisterReservedNamespace) is an exception — its owner registers it via + // Alter as schemas are written. + if x.IsReservedPredicate(edge.Attr) && !x.IsRegisteredReservedPredicate(edge.Attr) { return errors.Errorf("Can't store predicate `%s` as it is prefixed with "+ "`dgraph.` which is reserved as the namespace for dgraph's internal "+ "types/predicates.", diff --git a/x/keys.go b/x/keys.go index 94112d07c03..754b30f6123 100644 --- a/x/keys.go +++ b/x/keys.go @@ -12,6 +12,7 @@ import ( "math" "strconv" "strings" + "sync" "github.com/pkg/errors" @@ -644,6 +645,117 @@ var aclPredicateMap = map[string]struct{}{ "dgraph.acl.rule": {}, } +// ReservedNamespace lets a plugin claim ownership of a sub-namespace under the +// reserved `dgraph.` prefix, so the predicates and types it owns can be created +// at runtime via Alter even though they are not part of the pre-defined initial +// schema. Register one from an init() with RegisterReservedNamespace. +// +// With no registration the registry is empty and every check below reports +// false, so a stock build keeps the pristine behavior: nothing under `dgraph.` +// may be created except the pre-defined names. +// +// Ownership is deliberately explicit — a dynamic prefix and/or an exact name +// allowlist — so registering a namespace cannot be used to admit arbitrary +// reserved predicates through /alter or /mutate. +type ReservedNamespace struct { + // All names below are bare (no namespace prefix); queries strip the + // namespace before matching, as the reserved-predicate checks already do. + // + // PredicatePrefix admits dynamically-named predicates by prefix (e.g. + // "dgraph.acme.rel."). Empty means the namespace owns no dynamic predicates. + PredicatePrefix string + // Predicates is the exact set of fixed predicate names the namespace owns. + Predicates []string + // Types is the exact set of type names the namespace owns. + Types []string + // ValueLocked is the subset of owned predicates whose stored *value* may + // only be written by a request whose context carries TrustMarker. Use it + // for predicates whose value is authoritative state the owner must control. + // Names are the bare predicate form (no namespace prefix), matched + // case-insensitively like Predicates above. + ValueLocked []string + // TrustMarker is a context key the owner's trusted in-process caller sets, + // via context.WithValue(ctx, TrustMarker, true), to authorize writing + // ValueLocked predicates. Required (non-nil) when ValueLocked is non-empty; + // RegisterReservedNamespace panics otherwise. + TrustMarker any +} + +var ( + reservedNsMu sync.RWMutex + reservedNsPrefixes []string + reservedNsPredicates = map[string]struct{}{} + reservedNsTypes = map[string]struct{}{} + reservedNsValueLocked = map[string]any{} // lowercased bare predicate -> TrustMarker +) + +// RegisterReservedNamespace records a plugin's ownership of names under the +// reserved `dgraph.` prefix. Call it from an init(); it is safe for concurrent +// use but is expected to run before the server starts handling requests. +func RegisterReservedNamespace(ns ReservedNamespace) { + if len(ns.ValueLocked) > 0 && ns.TrustMarker == nil { + panic("x.RegisterReservedNamespace: ValueLocked is set but TrustMarker is nil; " + + "a value-locked predicate with no TrustMarker is unwritable by everyone, including its owner") + } + reservedNsMu.Lock() + defer reservedNsMu.Unlock() + if ns.PredicatePrefix != "" { + reservedNsPrefixes = append(reservedNsPrefixes, strings.ToLower(ns.PredicatePrefix)) + } + for _, p := range ns.Predicates { + reservedNsPredicates[strings.ToLower(p)] = struct{}{} + } + for _, t := range ns.Types { + reservedNsTypes[strings.ToLower(t)] = struct{}{} + } + for _, p := range ns.ValueLocked { + reservedNsValueLocked[strings.ToLower(p)] = ns.TrustMarker + } +} + +// IsRegisteredReservedPredicate reports whether pred is owned by a registered +// ReservedNamespace (by dynamic prefix or exact name). The alter validator and +// the no-schema mutation guard allow such predicates through even though they +// are not pre-defined. With no registration it always reports false. +func IsRegisteredReservedPredicate(pred string) bool { + p := strings.ToLower(ParseAttr(pred)) + reservedNsMu.RLock() + defer reservedNsMu.RUnlock() + for _, prefix := range reservedNsPrefixes { + if strings.HasPrefix(p, prefix) { + return true + } + } + _, ok := reservedNsPredicates[p] + return ok +} + +// IsRegisteredReservedType reports whether typ is a type owned by a registered +// ReservedNamespace. With no registration it always reports false. +func IsRegisteredReservedType(typ string) bool { + t := strings.ToLower(ParseAttr(typ)) + reservedNsMu.RLock() + defer reservedNsMu.RUnlock() + _, ok := reservedNsTypes[t] + return ok +} + +// ReservedPredicateValueLock reports whether pred's value is locked to a +// trusted writer and, if so, returns the context key (TrustMarker) that +// authorizes the write. The mutation-value guard rejects a write of a locked +// predicate when the request context does not carry that marker. +// +// pred is matched case-insensitively (like the predicate/type lookups above) so +// a value lock cannot be bypassed by changing the case of an owned name. Unlike +// those lookups it does not ParseAttr: the guard passes the bare predicate (no +// namespace separator), matching how IsOtherReservedPredicate is consulted. +func ReservedPredicateValueLock(pred string) (marker any, locked bool) { + reservedNsMu.RLock() + defer reservedNsMu.RUnlock() + marker, locked = reservedNsValueLocked[strings.ToLower(pred)] + return marker, locked +} + // TODO: rename this map to a better suited name as per its properties. It is not just for GraphQL // predicates, but for all those which are PreDefined and whose value is not allowed to be mutated // by users. When renaming this also rename the IsGraphql context key in edgraph/server.go. diff --git a/x/reserved_namespace_test.go b/x/reserved_namespace_test.go new file mode 100644 index 00000000000..22561cc0e0e --- /dev/null +++ b/x/reserved_namespace_test.go @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package x + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type testTrustKey int + +const testTrust testTrustKey = iota + +// ns namespace-attributes a bare name, the way the alter validator and +// no-schema mutation guard see predicate/type names at the call sites. +func ns(name string) string { return NamespaceAttr(RootNamespace, name) } + +func TestReservedNamespaceRegistry(t *testing.T) { + RegisterReservedNamespace(ReservedNamespace{ + PredicatePrefix: "dgraph.testns.rel.", + Predicates: []string{"dgraph.testns.xid", "dgraph.testns.cfg"}, + Types: []string{"dgraph.testns.node"}, + ValueLocked: []string{"dgraph.testns.cfg"}, + TrustMarker: testTrust, + }) + + // Dynamic prefix members. + require.True(t, IsRegisteredReservedPredicate(ns("dgraph.testns.rel.owner"))) + // Exact predicate members. + require.True(t, IsRegisteredReservedPredicate(ns("dgraph.testns.xid"))) + require.True(t, IsRegisteredReservedPredicate(ns("dgraph.testns.cfg"))) + // Not owned: a sibling under the same prefix root, a different reserved + // namespace, and an ordinary predicate. + require.False(t, IsRegisteredReservedPredicate(ns("dgraph.testns.other"))) + require.False(t, IsRegisteredReservedPredicate(ns("dgraph.graphql.schema"))) + require.False(t, IsRegisteredReservedPredicate(ns("person.name"))) + + // Types are tracked separately from predicates. + require.True(t, IsRegisteredReservedType(ns("dgraph.testns.node"))) + require.False(t, IsRegisteredReservedType(ns("dgraph.testns.xid"))) // a predicate, not a type + require.False(t, IsRegisteredReservedType(ns("dgraph.graphql"))) + + // Value lock matches the bare predicate, like IsOtherReservedPredicate. + // cfg is locked to the registered marker; xid is owned but not locked. + marker, locked := ReservedPredicateValueLock("dgraph.testns.cfg") + require.True(t, locked) + require.Equal(t, testTrust, marker) + _, locked = ReservedPredicateValueLock("dgraph.testns.xid") + require.False(t, locked) +} + +// TestReservedNamespaceRejectsUnregistered confirms names no namespace claims +// are never members, so a stock build with no registration keeps the pristine +// reserved-namespace behavior (only pre-defined names exist under `dgraph.`). +func TestReservedNamespaceRejectsUnregistered(t *testing.T) { + require.False(t, IsRegisteredReservedPredicate(ns("dgraph.unregistered.pred"))) + require.False(t, IsRegisteredReservedType(ns("dgraph.unregistered.type"))) + _, locked := ReservedPredicateValueLock("dgraph.unregistered.pred") + require.False(t, locked) +} + +// TestReservedPredicateValueLockCaseInsensitive guards against bypassing a value +// lock by changing the case of an owned name: ownership is matched +// case-insensitively, so the value lock must be too. +func TestReservedPredicateValueLockCaseInsensitive(t *testing.T) { + RegisterReservedNamespace(ReservedNamespace{ + Predicates: []string{"dgraph.casetest.Secret"}, + ValueLocked: []string{"dgraph.casetest.Secret"}, + TrustMarker: testTrust, + }) + + for _, p := range []string{"dgraph.casetest.Secret", "dgraph.casetest.secret", "dgraph.casetest.SECRET"} { + marker, locked := ReservedPredicateValueLock(p) + require.Truef(t, locked, "value lock must hold regardless of case: %q", p) + require.Equal(t, testTrust, marker) + } +} + +// TestRegisterReservedNamespaceRequiresTrustMarker confirms the invariant is +// enforced at registration (init time): ValueLocked without a TrustMarker would +// make the predicate unwritable by everyone, so it panics rather than failing +// silently at mutation time. +func TestRegisterReservedNamespaceRequiresTrustMarker(t *testing.T) { + require.Panics(t, func() { + RegisterReservedNamespace(ReservedNamespace{ + Predicates: []string{"dgraph.nomarker.cfg"}, + ValueLocked: []string{"dgraph.nomarker.cfg"}, + // TrustMarker intentionally left nil. + }) + }) +}