feat: implement user schema management (#7416)

This PR adds the functionality to manage user schemas through the new user schema service.
It includes the possibility to create a basic JSON schema and also provides a way on defining permissions (read, write) for owner and self context with an annotation.

Further annotations for OIDC claims and SAML attribute mappings will follow.

A guide on how to create a schema and assign permissions has been started. It will be extended though out the process of implementing the schema and users based on those.

Note:
This feature is in an early stage and therefore not enabled by default. To test it out, please enable the UserSchema feature flag on your instance / system though the feature service.
This commit is contained in:
Livio Spring
2024-03-12 14:50:13 +01:00
committed by GitHub
parent 2a39cc16f5
commit 0e181b218c
61 changed files with 3614 additions and 35 deletions

View File

@@ -0,0 +1,120 @@
package schema
import (
_ "embed"
"github.com/santhosh-tekuri/jsonschema/v5"
"github.com/zitadel/zitadel/internal/zerrors"
)
var (
//go:embed permission.schema.v1.json
permissionJSON string
permissionSchema = jsonschema.MustCompileString(PermissionSchemaID, permissionJSON)
)
const (
PermissionSchemaID = "urn:zitadel:schema:permission-schema:v1"
PermissionProperty = "urn:zitadel:schema:permission"
)
type role int32
const (
roleUnspecified role = iota
roleSelf
roleOwner
)
type permissionExtension struct {
role role
}
// Compile implements the [jsonschema.ExtCompiler] interface.
// It parses the permission schema extension / annotation of the passed field.
func (c permissionExtension) Compile(ctx jsonschema.CompilerContext, m map[string]interface{}) (_ jsonschema.ExtSchema, err error) {
perm, ok := m[PermissionProperty]
if !ok {
return nil, nil
}
p, ok := perm.(map[string]interface{})
if !ok {
return nil, zerrors.ThrowInvalidArgument(nil, "SCHEMA-WR5gs", "invalid permission")
}
perms := new(permissions)
for key, value := range p {
switch key {
case "self":
perms.self, err = mapPermission(value)
if err != nil {
return
}
case "owner":
perms.owner, err = mapPermission(value)
if err != nil {
return
}
default:
return nil, zerrors.ThrowInvalidArgument(nil, "SCHEMA-GFjio", "invalid permission role")
}
}
return permissionExtensionConfig{c.role, perms}, nil
}
type permissionExtensionConfig struct {
role role
permissions *permissions
}
// Validate implements the [jsonschema.ExtSchema] interface.
// It validates the fields of the json instance according to the permission schema.
func (s permissionExtensionConfig) Validate(ctx jsonschema.ValidationContext, v interface{}) error {
switch s.role {
case roleSelf:
if s.permissions.self == nil || !s.permissions.self.write {
return ctx.Error("permission", "missing required permission")
}
return nil
case roleOwner:
if s.permissions.owner == nil || !s.permissions.owner.write {
return ctx.Error("permission", "missing required permission")
}
return nil
case roleUnspecified:
fallthrough
default:
return ctx.Error("permission", "missing required permission")
}
}
func mapPermission(value any) (*permission, error) {
p := new(permission)
switch v := value.(type) {
case string:
for _, s := range v {
switch s {
case 'r':
p.read = true
case 'w':
p.write = true
default:
return nil, zerrors.ThrowInvalidArgumentf(nil, "SCHEMA-EZ5zjh", "invalid permission pattern: `%s` in (%s)", string(s), v)
}
}
return p, nil
default:
return nil, zerrors.ThrowInvalidArgumentf(nil, "SCHEMA-E5h31", "invalid permission type %T (%v)", v, v)
}
}
type permissions struct {
self *permission
owner *permission
}
type permission struct {
read bool
write bool
}

View File

@@ -0,0 +1,28 @@
{
"$id": "urn:zitadel:schema:permission-schema:v1",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"urn:zitadel:schema:property-permission": {
"oneOf": [
{
"type": "string",
"pattern": "^[rw]$"
}
]
}
},
"properties": {
"urn:zitadel:schema:permission": {
"type": "object",
"additionalProperties": false,
"properties": {
"owner": {
"$ref": "#/$defs/urn:zitadel:schema:property-permission"
},
"self": {
"$ref": "#/$defs/urn:zitadel:schema:property-permission"
}
}
}
}
}

View File

@@ -0,0 +1,253 @@
package schema
import (
_ "embed"
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestPermissionExtension(t *testing.T) {
type args struct {
role role
schema string
instance string
}
type want struct {
compilationErr error
validationErr bool
}
tests := []struct {
name string
args args
want want
}{
{
"invalid permission, compilation err",
args{
schema: `{
"type": "object",
"properties": {
"name": {
"type": "string",
"urn:zitadel:schema:permission": "read"
}
}
}`,
},
want{
compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-WR5gs", "invalid permission"),
},
},
{
"invalid permission string, compilation err",
args{
schema: `{
"type": "object",
"properties": {
"name": {
"type": "string",
"urn:zitadel:schema:permission": {
"self": "read"
}
}
}
}`,
},
want{
compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-EZ5zjh", "invalid permission pattern: `e` in (read)"),
},
},
{
"invalid permission type, compilation err",
args{
schema: `{
"type": "object",
"properties": {
"name": {
"type": "string",
"urn:zitadel:schema:permission": {
"owner": true
}
}
}
}`,
},
want{
compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-E5h31", "invalid permission type bool (true)"),
},
},
{
"invalid role, compilation err",
args{
schema: `{
"type": "object",
"properties": {
"name": {
"type": "string",
"urn:zitadel:schema:permission": {
"IAM_OWNER": "rw"
}
}
}
}`,
},
want{
compilationErr: zerrors.ThrowInvalidArgument(nil, "SCHEMA-GFjio", "invalid permission role"),
},
},
{
"invalid permission self, validation err",
args{
role: roleSelf,
schema: `{
"type": "object",
"properties": {
"name": {
"type": "string",
"urn:zitadel:schema:permission": {
"owner": "rw",
"self": "r"
}
}
}
}`,
instance: `{ "name": "test"}`,
},
want{
validationErr: true,
},
},
{
"invalid permission owner, validation err",
args{
role: roleOwner,
schema: `{
"type": "object",
"properties": {
"name": {
"type": "string",
"urn:zitadel:schema:permission": {
"owner": "r",
"self": "r"
}
}
}
}`,
instance: `{ "name": "test"}`,
},
want{
validationErr: true,
},
},
{
"valid permission self, ok",
args{
role: roleSelf,
schema: `{
"type": "object",
"properties": {
"name": {
"type": "string",
"urn:zitadel:schema:permission": {
"owner": "r",
"self": "rw"
}
}
}
}`,
instance: `{ "name": "test"}`,
},
want{
validationErr: false,
},
},
{
"valid permission owner, ok",
args{
role: roleOwner,
schema: `{
"type": "object",
"properties": {
"name": {
"type": "string",
"urn:zitadel:schema:permission": {
"owner": "rw",
"self": "r"
}
}
}
}`,
instance: `{ "name": "test"}`,
},
want{
validationErr: false,
},
},
{
"no role, validation err",
args{
role: roleUnspecified,
schema: `{
"type": "object",
"properties": {
"name": {
"type": "string",
"urn:zitadel:schema:permission": {
"owner": "rw",
"self": "rw"
}
}
}
}`,
instance: `{ "name": "test"}`,
},
want{
validationErr: true,
},
},
{
"no permission required, ok",
args{
role: roleSelf,
schema: `{
"type": "object",
"properties": {
"name": {
"type": "string"
}
}
}`,
instance: `{ "name": "test"}`,
},
want{
validationErr: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schema, err := NewSchema(tt.args.role, strings.NewReader(tt.args.schema))
require.ErrorIs(t, err, tt.want.compilationErr)
if tt.want.compilationErr != nil {
return
}
var v interface{}
err = json.Unmarshal([]byte(tt.args.instance), &v)
require.NoError(t, err)
err = schema.Validate(v)
if tt.want.validationErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
})
}
}

View File

@@ -0,0 +1,41 @@
package schema
import (
_ "embed"
"io"
"strings"
"github.com/santhosh-tekuri/jsonschema/v5"
"github.com/zitadel/zitadel/internal/zerrors"
)
var (
//go:embed zitadel.schema.v1.json
zitadelJSON string
)
const (
MetaSchemaID = "urn:zitadel:schema:v1"
)
func NewSchema(role role, r io.Reader) (*jsonschema.Schema, error) {
c := jsonschema.NewCompiler()
if err := c.AddResource(PermissionSchemaID, strings.NewReader(permissionJSON)); err != nil {
return nil, err
}
if err := c.AddResource(MetaSchemaID, strings.NewReader(zitadelJSON)); err != nil {
return nil, err
}
c.RegisterExtension(PermissionSchemaID, permissionSchema, permissionExtension{
role,
})
if err := c.AddResource("schema.json", r); err != nil {
return nil, zerrors.ThrowInvalidArgument(err, "COMMA-Frh42", "Errors.UserSchema.Schema.Invalid")
}
schema, err := c.Compile("schema.json")
if err != nil {
return nil, zerrors.ThrowInvalidArgument(err, "COMMA-W21tg", "Errors.UserSchema.Schema.Invalid")
}
return schema, nil
}

View File

@@ -0,0 +1,13 @@
{
"$id": "urn:zitadel:schema:v1",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"allOf": [
{
"$ref": "https://json-schema.org/draft/2020-12/schema"
},
{
"$ref": "urn:zitadel:schema:permission-schema:v1"
}
]
}

View File

@@ -0,0 +1,26 @@
package domain
type UserSchemaState int32
const (
UserSchemaStateUnspecified UserSchemaState = iota
UserSchemaStateActive
UserSchemaStateInactive
UserSchemaStateDeleted
userSchemaStateCount
)
type AuthenticatorType int32
const (
AuthenticatorTypeUnspecified AuthenticatorType = iota
AuthenticatorTypeUsername
AuthenticatorTypePassword
AuthenticatorTypeWebAuthN
AuthenticatorTypeTOTP
AuthenticatorTypeOTPEmail
AuthenticatorTypeOTPSMS
AuthenticatorTypeAuthenticationKey
AuthenticatorTypeIdentityProvider
authenticatorTypeCount
)