chore: move the go code into a subfolder

This commit is contained in:
Florian Forster
2025-08-05 15:20:32 -07:00
parent 4ad22ba456
commit cd2921de26
2978 changed files with 373 additions and 300 deletions

View File

@@ -0,0 +1,141 @@
package actions
import (
"context"
"errors"
"fmt"
"github.com/dop251/goja_nodejs/require"
"github.com/sirupsen/logrus"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/zerrors"
)
type Config struct {
HTTP HTTPConfig
}
var ErrHalt = errors.New("interrupt")
type jsAction func(fields, fields) error
const (
actionStartedMessage = "action run started"
actionSucceededMessage = "action run succeeded"
)
func actionFailedMessage(err error) string {
return fmt.Sprintf("action run failed: %s", err.Error())
}
func Run(ctx context.Context, ctxParam contextFields, apiParam apiFields, script, name string, opts ...Option) (err error) {
config := newRunConfig(ctx, append(opts, withLogger(ctx))...)
if config.functionTimeout == 0 {
return zerrors.ThrowInternal(nil, "ACTIO-uCpCx", "Errrors.Internal")
}
remaining := logstoreService.Limit(ctx, config.instanceID)
config.cutTimeouts(remaining)
config.logger.Log(actionStartedMessage)
if remaining != nil && *remaining == 0 {
return zerrors.ThrowResourceExhausted(nil, "ACTIO-f19Ii", "Errors.Quota.Execution.Exhausted")
}
defer func() {
if err != nil {
config.logger.log(actionFailedMessage(err), logrus.ErrorLevel, true)
} else {
config.logger.log(actionSucceededMessage, logrus.InfoLevel, true)
}
if config.allowedToFail {
err = nil
}
}()
if err := executeScript(config, ctxParam, apiParam, script); err != nil {
return err
}
var fn jsAction
jsFn := config.vm.Get(name)
if jsFn == nil {
return errors.New("function not found")
}
if err := config.vm.ExportTo(jsFn, &fn); err != nil {
return err
}
t := config.StartFunction()
defer func() {
t.Stop()
}()
return executeFn(config, fn)
}
func executeScript(config *runConfig, ctxParam contextFields, apiParam apiFields, script string) (err error) {
t := config.StartScript()
defer func() {
t.Stop()
}()
if ctxParam != nil {
ctxParam(config.ctxParam)
}
if apiParam != nil {
apiParam(config.apiParam)
}
registry := new(require.Registry)
registry.Enable(config.vm)
for name, loader := range config.modules {
registry.RegisterNativeModule(name, loader)
}
// overload error if function panics
defer func() {
r := recover()
if r != nil {
err = r.(error)
return
}
}()
_, err = config.vm.RunString(script)
return err
}
func executeFn(config *runConfig, fn jsAction) (err error) {
defer func() {
r := recover()
if r == nil {
return
}
var ok bool
if err, ok = r.(error); ok {
return
}
e, ok := r.(string)
if ok {
err = errors.New(e)
return
}
err = fmt.Errorf("unknown error occurred: %v", r)
}()
if err = fn(config.ctxParam.fields, config.apiParam.fields); err != nil {
return err
}
return nil
}
func ActionToOptions(a *query.Action) []Option {
opts := make([]Option, 0, 1)
if a.AllowedToFail {
opts = append(opts, WithAllowedToFail())
}
return opts
}

View File

@@ -0,0 +1,73 @@
package actions
import (
"context"
"errors"
"testing"
"time"
"github.com/dop251/goja"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/record"
)
func TestRun(t *testing.T) {
SetLogstoreService(logstore.New[*record.ExecutionLog](nil, nil))
type args struct {
timeout time.Duration
api apiFields
ctx contextFields
script string
name string
opts []Option
}
tests := []struct {
name string
args args
wantErr func(error) bool
}{
{
name: "simple script",
args: args{
api: nil,
script: `
function testFunc() {
for (i = 0; i < 10; i++) {}
}`,
name: "testFunc",
opts: []Option{},
},
wantErr: func(err error) bool { return err == nil },
},
{
name: "throw error",
args: args{
api: nil,
script: "function testFunc() {throw 'some error'}",
name: "testFunc",
opts: []Option{},
},
wantErr: func(err error) bool {
gojaErr := new(goja.Exception)
if errors.As(err, &gojaErr) {
return gojaErr.Value().String() == "some error"
}
return false
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.args.timeout == 0 {
tt.args.timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), tt.args.timeout)
if err := Run(ctx, tt.args.ctx, tt.args.api, tt.args.script, tt.args.name, tt.args.opts...); !tt.wantErr(err) {
t.Errorf("Run() unexpected error = (%[1]T) %[1]v", err)
}
cancel()
})
}
}

View File

@@ -0,0 +1,19 @@
package actions
type apiConfig struct {
FieldConfig
}
type apiFields func(*apiConfig)
func WithAPIFields(opts ...FieldOption) apiFields {
return func(p *apiConfig) {
if p.fields == nil {
p.fields = fields{}
}
for _, opt := range opts {
opt(&p.FieldConfig)
}
}
}

View File

@@ -0,0 +1,101 @@
package actions
import (
"context"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/require"
"github.com/zitadel/logging"
)
const (
maxPrepareTimeout = 5 * time.Second
)
type Option func(*runConfig)
func WithAllowedToFail() Option {
return func(c *runConfig) {
c.allowedToFail = true
}
}
type runConfig struct {
allowedToFail bool
functionTimeout,
scriptTimeout time.Duration
modules map[string]require.ModuleLoader
logger *logger
instanceID string
vm *goja.Runtime
ctxParam *ctxConfig
apiParam *apiConfig
}
func newRunConfig(ctx context.Context, opts ...Option) *runConfig {
deadline, ok := ctx.Deadline()
if !ok {
logging.Warn("no timeout set on action run")
}
vm := goja.New()
vm.SetFieldNameMapper(goja.UncapFieldNameMapper())
config := &runConfig{
functionTimeout: time.Until(deadline),
scriptTimeout: maxPrepareTimeout,
modules: map[string]require.ModuleLoader{},
vm: vm,
ctxParam: &ctxConfig{
FieldConfig: FieldConfig{
Runtime: vm,
fields: fields{},
},
},
apiParam: &apiConfig{
FieldConfig: FieldConfig{
Runtime: vm,
fields: fields{},
},
},
}
for _, opt := range opts {
opt(config)
}
if config.scriptTimeout > config.functionTimeout {
config.scriptTimeout = config.functionTimeout
}
return config
}
func (c *runConfig) StartFunction() *time.Timer {
c.vm.ClearInterrupt()
return time.AfterFunc(c.functionTimeout, func() {
c.vm.Interrupt(ErrHalt)
})
}
func (c *runConfig) StartScript() *time.Timer {
c.vm.ClearInterrupt()
return time.AfterFunc(c.scriptTimeout, func() {
c.vm.Interrupt(ErrHalt)
})
}
func (c *runConfig) cutTimeouts(remainingSeconds *uint64) {
if remainingSeconds == nil {
return
}
remainingDur := time.Duration(*remainingSeconds) * time.Second
if c.functionTimeout > remainingDur {
c.functionTimeout = remainingDur
}
if c.scriptTimeout > remainingDur {
c.scriptTimeout = remainingDur
}
}

View File

@@ -0,0 +1,19 @@
package actions
type ctxConfig struct {
FieldConfig
}
type contextFields func(*ctxConfig)
func SetContextFields(opts ...FieldOption) contextFields {
return func(p *ctxConfig) {
if p.fields == nil {
p.fields = fields{}
}
for _, opt := range opts {
opt(&p.FieldConfig)
}
}
}

View File

@@ -0,0 +1,73 @@
package actions
import (
"github.com/dop251/goja"
"github.com/zitadel/logging"
)
type fields map[string]interface{}
type FieldOption func(*FieldConfig)
type FieldConfig struct {
fields
Runtime *goja.Runtime
}
func SetFields(name string, values ...interface{}) FieldOption {
return func(p *FieldConfig) {
if len(values) == 0 {
return
}
for _, value := range values {
val, ok := value.(FieldOption)
// is the lowest field and can be set without further checks
if !ok {
// {
// "value": "some value"
// }
p.set(name, value)
continue
}
var field fields
if f, ok := p.fields[name]; ok {
// check if the found field is an object
if field, ok = f.(fields); !ok {
// panic because overwriting fields is not allowed
logging.WithFields("sub", name).Warn("sub is not an object")
panic("unable to prepare parameter")
}
} else {
// field does not exist so far.
// sub object for field can be created
field = fields{}
p.fields[name] = field
}
fieldParam := FieldConfig{
Runtime: p.Runtime,
fields: field,
}
val(&fieldParam)
}
}
}
func (f *FieldConfig) set(name string, value interface{}) {
if _, ok := f.fields[name]; ok {
logging.WithFields("name", name).Error("tried to overwrite field")
panic("tried to overwrite field")
}
switch v := value.(type) {
case func(config *FieldConfig) interface{}:
f.fields[name] = v(f)
case func(config *FieldConfig) func(call goja.FunctionCall) goja.Value:
f.fields[name] = v(f)
default:
f.fields[name] = value
}
}

View File

@@ -0,0 +1,190 @@
package actions
import (
"fmt"
"testing"
"github.com/dop251/goja"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/record"
)
func TestSetFields(t *testing.T) {
SetLogstoreService(logstore.New[*record.ExecutionLog](nil, nil))
primitveFn := func(a string) { fmt.Println(a) }
complexFn := func(*FieldConfig) interface{} {
return primitveFn
}
tests := []struct {
name string
setFields FieldOption
want fields
shouldPanic bool
}{
{
name: "field is simple value",
setFields: SetFields("value", 5),
want: fields{
"value": 5,
},
},
{
name: "field is method",
setFields: SetFields("value", primitveFn),
want: fields{
"value": primitveFn,
},
},
{
name: "field is complex method",
setFields: SetFields("value", complexFn),
want: fields{
"value": primitveFn,
},
},
{
name: "field without value",
setFields: SetFields("value"),
want: fields{},
},
{
name: "field with empty value",
setFields: SetFields("value", ""),
want: fields{
"value": "",
},
},
{
name: "nested simple value",
setFields: SetFields(
"field",
SetFields("sub", 5),
),
want: fields{
"field": fields{
"sub": 5,
},
},
},
{
name: "nested multiple fields",
setFields: SetFields(
"field",
SetFields("sub1", 5),
SetFields("sub2", "asdf"),
SetFields("sub3", primitveFn),
),
want: fields{
"field": fields{
"sub1": 5,
"sub2": "asdf",
"sub3": primitveFn,
},
},
},
{
name: "try to overwrite field primitives",
setFields: SetFields(
"field",
SetFields("sub", 5),
SetFields("sub", primitveFn),
),
shouldPanic: true,
},
{
name: "try to overwrite primitives with fields",
setFields: SetFields(
"field",
SetFields("sub", 5),
SetFields("sub", SetFields("please", "panic")),
),
shouldPanic: true,
},
{
name: "try to overwrite fields with primitive",
setFields: SetFields(
"field",
SetFields("sub", SetFields("please", "panic")),
SetFields("sub", 5),
),
shouldPanic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
panicked := false
if tt.shouldPanic {
defer func() {
if panicked != tt.shouldPanic {
t.Errorf("wanted panic: %v got %v", tt.shouldPanic, panicked)
}
}()
defer func() {
recover()
panicked = true
}()
}
config := &FieldConfig{
Runtime: goja.New(),
fields: fields{},
}
tt.setFields(config)
if !tt.shouldPanic && fmt.Sprint(config.fields) != fmt.Sprint(tt.want) {
t.Errorf("SetFields() = %v, want %v", fmt.Sprint(config.fields), fmt.Sprint(tt.want))
}
})
}
}
func TestSetFieldsExecuteMethods(t *testing.T) {
primitveFn := func(a string) { fmt.Println(a) }
complexFn := func(*FieldConfig) interface{} {
return primitveFn
}
tests := []struct {
name string
setFields FieldOption
want fields
shouldPanic bool
}{
{
name: "field is method",
setFields: SetFields("value", primitveFn),
want: fields{
"value": primitveFn,
},
},
{
name: "field is complex method",
setFields: SetFields("value", complexFn),
want: fields{
"value": primitveFn,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
panicked := false
if tt.shouldPanic {
defer func() {
if panicked != tt.shouldPanic {
t.Errorf("wanted panic: %v got %v", tt.shouldPanic, panicked)
}
}()
defer func() {
recover()
panicked = true
}()
}
config := &FieldConfig{
Runtime: goja.New(),
fields: fields{},
}
tt.setFields(config)
if !tt.shouldPanic && fmt.Sprint(config.fields) != fmt.Sprint(tt.want) {
t.Errorf("SetFields() = %v, want %v", fmt.Sprint(config.fields), fmt.Sprint(tt.want))
}
})
}
}

View File

@@ -0,0 +1,210 @@
package actions
import (
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/dop251/goja"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/zerrors"
)
func WithHTTP(ctx context.Context) Option {
return func(c *runConfig) {
c.modules["zitadel/http"] = func(runtime *goja.Runtime, module *goja.Object) {
requireHTTP(ctx, &http.Client{Transport: &transport{lookup: net.LookupIP}}, runtime, module)
}
}
}
type HTTP struct {
runtime *goja.Runtime
client *http.Client
}
func requireHTTP(ctx context.Context, client *http.Client, runtime *goja.Runtime, module *goja.Object) {
c := &HTTP{
client: client,
runtime: runtime,
}
o := module.Get("exports").(*goja.Object)
logging.OnError(o.Set("fetch", c.fetch(ctx))).Warn("unable to set module")
}
type fetchConfig struct {
Method string
Headers http.Header
Body io.Reader
}
var defaultFetchConfig = fetchConfig{
Method: http.MethodGet,
Headers: http.Header{
"Content-Type": []string{"application/json"},
"Accept": []string{"application/json"},
},
}
func (c *HTTP) fetchConfigFromArg(arg *goja.Object, config *fetchConfig) (err error) {
for _, key := range arg.Keys() {
switch key {
case "headers":
config.Headers = parseHeaders(arg.Get(key).ToObject(c.runtime))
case "method":
config.Method = arg.Get(key).String()
case "body":
body, err := arg.Get(key).ToObject(c.runtime).MarshalJSON()
if err != nil {
return err
}
config.Body = bytes.NewReader(body)
default:
return zerrors.ThrowInvalidArgument(nil, "ACTIO-OfUeA", "key is invalid")
}
}
return nil
}
type response struct {
Body string
Status int
Headers map[string][]string
runtime *goja.Runtime
}
func (r *response) Json() goja.Value {
var val interface{}
if err := json.Unmarshal([]byte(r.Body), &val); err != nil {
panic(err)
}
return r.runtime.ToValue(val)
}
func (r *response) Text() goja.Value {
return r.runtime.ToValue(r.Body)
}
func (c *HTTP) fetch(ctx context.Context) func(call goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
req := c.buildHTTPRequest(ctx, call.Arguments)
if deadline, ok := ctx.Deadline(); ok {
c.client.Timeout = time.Until(deadline)
}
res, err := c.client.Do(req)
if err != nil {
logging.WithError(err).Debug("call failed")
panic(err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
logging.WithError(err).Warn("unable to parse body")
panic("unable to read response body")
}
return c.runtime.ToValue(&response{Status: res.StatusCode, Body: string(body), runtime: c.runtime})
}
}
// the first argument has to be a string and is required
// the second agrument is optional and an object with the following fields possible:
// - `Headers`: map with string key and value of type string or string array
// - `Body`: json body of the request
// - `Method`: http method type
func (c *HTTP) buildHTTPRequest(ctx context.Context, args []goja.Value) (req *http.Request) {
if len(args) > 2 {
logging.WithFields("count", len(args)).Debug("more than 2 args provided")
panic("too many args")
}
if len(args) == 0 {
panic("no url provided")
}
config := defaultFetchConfig
var err error
if len(args) == 2 {
if err = c.fetchConfigFromArg(args[1].ToObject(c.runtime), &config); err != nil {
panic(err)
}
}
req, err = http.NewRequestWithContext(ctx, config.Method, args[0].Export().(string), config.Body)
if err != nil {
panic(err)
}
req.Header = config.Headers
return req
}
func parseHeaders(headers *goja.Object) http.Header {
h := make(http.Header, len(headers.Keys()))
for _, k := range headers.Keys() {
header := headers.Get(k).Export()
var values []string
switch headerValue := header.(type) {
case string:
values = strings.Split(headerValue, ",")
case []any:
for _, v := range headerValue {
values = append(values, v.(string))
}
}
for _, v := range values {
h.Add(k, strings.TrimSpace(v))
}
}
return h
}
type transport struct {
lookup func(string) ([]net.IP, error)
}
func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) {
if httpConfig == nil || len(httpConfig.DenyList) == 0 {
return http.DefaultTransport.RoundTrip(req)
}
if err := t.isHostBlocked(httpConfig.DenyList, req.URL); err != nil {
return nil, zerrors.ThrowInvalidArgument(err, "ACTIO-N72d0", "host is denied")
}
return http.DefaultTransport.RoundTrip(req)
}
func (t *transport) isHostBlocked(denyList []AddressChecker, address *url.URL) error {
host := address.Hostname()
ip := net.ParseIP(host)
ips := []net.IP{ip}
// if the hostname is a domain, we need to check resolve the ip(s), since it might be denied
if ip == nil {
var err error
ips, err = t.lookup(host)
if err != nil {
return zerrors.ThrowInternal(err, "ACTIO-4m9s2", "lookup failed")
}
}
for _, denied := range denyList {
if err := denied.IsDenied(ips, host); err != nil {
return err
}
}
return nil
}
type AddressChecker interface {
IsDenied([]net.IP, string) error
}

View File

@@ -0,0 +1,119 @@
package actions
import (
"errors"
"fmt"
"net"
"reflect"
"strings"
"github.com/mitchellh/mapstructure"
)
func SetHTTPConfig(config *HTTPConfig) {
httpConfig = config
}
var httpConfig *HTTPConfig
type HTTPConfig struct {
DenyList []AddressChecker
}
func HTTPConfigDecodeHook(from, to reflect.Value) (interface{}, error) {
if to.Type() != reflect.TypeOf(HTTPConfig{}) {
return from.Interface(), nil
}
config := struct {
DenyList []string
}{}
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
DecodeHook: mapstructure.StringToTimeDurationHookFunc(),
WeaklyTypedInput: true,
Result: &config,
})
if err != nil {
return nil, err
}
if err = decoder.Decode(from.Interface()); err != nil {
return nil, err
}
c := HTTPConfig{
DenyList: make([]AddressChecker, 0),
}
for _, unsplit := range config.DenyList {
for _, split := range strings.Split(unsplit, ",") {
parsed, parseErr := NewHostChecker(split)
if parseErr != nil {
return nil, parseErr
}
if parsed != nil {
c.DenyList = append(c.DenyList, parsed)
}
}
}
return c, nil
}
func NewHostChecker(entry string) (AddressChecker, error) {
if entry == "" {
return nil, nil
}
_, network, err := net.ParseCIDR(entry)
if err == nil {
return &HostChecker{Net: network}, nil
}
if ip := net.ParseIP(entry); ip != nil {
return &HostChecker{IP: ip}, nil
}
return &HostChecker{Domain: entry}, nil
}
type HostChecker struct {
Net *net.IPNet
IP net.IP
Domain string
}
type AddressDeniedError struct {
deniedBy string
}
func NewAddressDeniedError(deniedBy string) *AddressDeniedError {
return &AddressDeniedError{deniedBy: deniedBy}
}
func (e *AddressDeniedError) Error() string {
return fmt.Sprintf("address is denied by '%s'", e.deniedBy)
}
func (e *AddressDeniedError) Is(target error) bool {
var addressDeniedErr *AddressDeniedError
if !errors.As(target, &addressDeniedErr) {
return false
}
return e.deniedBy == addressDeniedErr.deniedBy
}
func (c *HostChecker) IsDenied(ips []net.IP, address string) error {
// if the address matches the domain, no additional checks as needed
if c.Domain == address {
return NewAddressDeniedError(c.Domain)
}
// otherwise we need to check on ips (incl. the resolved ips of the host)
for _, ip := range ips {
if c.Net != nil && c.Net.Contains(ip) {
return NewAddressDeniedError(c.Net.String())
}
if c.IP != nil && c.IP.Equal(ip) {
return NewAddressDeniedError(c.IP.String())
}
}
return nil
}

View File

@@ -0,0 +1,480 @@
package actions
import (
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"net/url"
"reflect"
"testing"
"github.com/dop251/goja"
"github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/record"
"github.com/zitadel/zitadel/internal/zerrors"
)
func Test_isHostBlocked(t *testing.T) {
SetLogstoreService(logstore.New[*record.ExecutionLog](nil, nil))
var denyList = []AddressChecker{
mustNewHostChecker(t, "192.168.5.0/24"),
mustNewHostChecker(t, "127.0.0.1"),
mustNewHostChecker(t, "test.com"),
}
type fields struct {
lookup func(host string) ([]net.IP, error)
}
type args struct {
address *url.URL
}
tests := []struct {
name string
fields fields
args args
want error
}{
{
name: "in range",
args: args{
address: mustNewURL(t, "https://192.168.5.4/hodor"),
},
want: NewAddressDeniedError("192.168.5.0/24"),
},
{
name: "exact ip",
args: args{
address: mustNewURL(t, "http://127.0.0.1:8080/hodor"),
},
want: NewAddressDeniedError("127.0.0.1"),
},
{
name: "address match",
fields: fields{
lookup: func(host string) ([]net.IP, error) {
return []net.IP{net.ParseIP("194.264.52.4")}, nil
},
},
args: args{
address: mustNewURL(t, "https://test.com:42/hodor"),
},
want: NewAddressDeniedError("test.com"),
},
{
name: "address not match",
fields: fields{
lookup: func(host string) ([]net.IP, error) {
return []net.IP{net.ParseIP("194.264.52.4")}, nil
},
},
args: args{
address: mustNewURL(t, "https://test2.com/hodor"),
},
want: nil,
},
{
name: "looked up ip matches",
fields: fields{
lookup: func(host string) ([]net.IP, error) {
return []net.IP{net.ParseIP("127.0.0.1")}, nil
},
},
args: args{
address: mustNewURL(t, "https://test2.com/hodor"),
},
want: NewAddressDeniedError("127.0.0.1"),
},
{
name: "looked up failure",
fields: fields{
lookup: func(host string) ([]net.IP, error) {
return nil, errors.New("some error")
},
},
args: args{
address: mustNewURL(t, "https://test2.com/hodor"),
},
want: zerrors.ThrowInternal(nil, "ACTIO-4m9s2", "lookup failed"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
trans := &transport{
lookup: tt.fields.lookup,
}
got := trans.isHostBlocked(denyList, tt.args.address)
assert.ErrorIs(t, got, tt.want)
})
}
}
func mustNewHostChecker(t *testing.T, ip string) AddressChecker {
t.Helper()
checker, err := NewHostChecker(ip)
if err != nil {
t.Errorf("unable to parse cidr of %q because: %v", ip, err)
t.FailNow()
}
return checker
}
func mustNewURL(t *testing.T, raw string) *url.URL {
u, err := url.Parse(raw)
if err != nil {
t.Errorf("unable to parse address of %q because: %v", raw, err)
t.FailNow()
}
return u
}
func TestHTTP_fetchConfigFromArg(t *testing.T) {
runtime := goja.New()
runtime.SetFieldNameMapper(goja.UncapFieldNameMapper())
type args struct {
arg *goja.Object
}
tests := []struct {
name string
args args
wantConfig fetchConfig
wantErr func(error) bool
}{
{
name: "no fetch option provided",
args: args{
arg: runtime.ToValue(
struct{}{},
).ToObject(runtime),
},
wantConfig: fetchConfig{},
wantErr: func(err error) bool {
return err == nil
},
},
{
name: "header set as string",
args: args{
arg: runtime.ToValue(
&struct {
Headers map[string]string
}{
Headers: map[string]string{
"Authorization": "Bearer token",
},
},
).ToObject(runtime),
},
wantConfig: fetchConfig{
Headers: http.Header{
"Authorization": {"Bearer token"},
},
},
wantErr: func(err error) bool {
return err == nil
},
},
{
name: "header set as list",
args: args{
arg: runtime.ToValue(
&struct {
Headers map[string][]any
}{
Headers: map[string][]any{
"Authorization": {"Bearer token"},
},
},
).ToObject(runtime),
},
wantConfig: fetchConfig{
Headers: http.Header{
"Authorization": {"Bearer token"},
},
},
wantErr: func(err error) bool {
return err == nil
},
},
{
name: "method set",
args: args{
arg: runtime.ToValue(
&struct {
Method string
}{
Method: http.MethodPost,
},
).ToObject(runtime),
},
wantConfig: fetchConfig{
Method: http.MethodPost,
},
wantErr: func(err error) bool {
return err == nil
},
},
{
name: "body set",
args: args{
arg: runtime.ToValue(
&struct {
Body struct{ Id string }
}{
Body: struct{ Id string }{
Id: "asdf123",
},
},
).ToObject(runtime),
},
wantConfig: fetchConfig{
Body: bytes.NewReader([]byte(`{"id":"asdf123"}`)),
},
wantErr: func(err error) bool {
return err == nil
},
},
{
name: "invalid header",
args: args{
arg: runtime.ToValue(
&struct {
NotExists struct{}
}{
NotExists: struct{}{},
},
).ToObject(runtime),
},
wantConfig: fetchConfig{},
wantErr: func(err error) bool {
return zerrors.IsErrorInvalidArgument(err)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &HTTP{
runtime: runtime,
client: http.DefaultClient,
}
gotConfig := new(fetchConfig)
err := c.fetchConfigFromArg(tt.args.arg, gotConfig)
if !tt.wantErr(err) {
t.Errorf("HTTP.fetchConfigFromArg() error = %v", err)
return
}
if !reflect.DeepEqual(gotConfig.Headers, tt.wantConfig.Headers) {
t.Errorf("config.Headers got = %#v, want %#v", gotConfig.Headers, tt.wantConfig.Headers)
}
if gotConfig.Method != tt.wantConfig.Method {
t.Errorf("config.Method got = %#v, want %#v", gotConfig.Method, tt.wantConfig.Method)
}
if tt.wantConfig.Body == nil {
if gotConfig.Body != nil {
t.Errorf("didn't expect a body")
}
return
}
gotBody, _ := io.ReadAll(gotConfig.Body)
wantBody, _ := io.ReadAll(tt.wantConfig.Body)
if !reflect.DeepEqual(gotBody, wantBody) {
t.Errorf("config.Body got = %s, want %s", gotBody, wantBody)
}
})
}
}
func TestHTTP_buildHTTPRequest(t *testing.T) {
runtime := goja.New()
runtime.SetFieldNameMapper(goja.UncapFieldNameMapper())
type args struct {
args []goja.Value
}
tests := []struct {
name string
args args
wantReq *http.Request
shouldPanic bool
}{
{
name: "only url",
args: args{
args: []goja.Value{
runtime.ToValue("http://my-url.ch"),
},
},
wantReq: &http.Request{
Method: http.MethodGet,
URL: mustNewURL(t, "http://my-url.ch"),
Header: defaultFetchConfig.Headers,
Body: nil,
},
},
{
name: "no params",
args: args{
args: []goja.Value{
runtime.ToValue("http://my-url.ch"),
runtime.ToValue(&struct{}{}),
},
},
wantReq: &http.Request{
Method: http.MethodGet,
URL: mustNewURL(t, "http://my-url.ch"),
Header: defaultFetchConfig.Headers,
Body: nil,
},
},
{
name: "overwrite headers",
args: args{
args: []goja.Value{
runtime.ToValue("http://my-url.ch"),
runtime.ToValue(struct {
Headers map[string][]interface{}
}{
Headers: map[string][]interface{}{"Authorization": {"some token"}},
}),
},
},
wantReq: &http.Request{
Method: http.MethodGet,
URL: mustNewURL(t, "http://my-url.ch"),
Header: http.Header{
"Authorization": []string{"some token"},
},
Body: nil,
},
},
{
name: "post with body",
args: args{
args: []goja.Value{
runtime.ToValue("http://my-url.ch"),
runtime.ToValue(struct {
Body struct{ MyData string }
}{
Body: struct{ MyData string }{MyData: "hello world"},
}),
},
},
wantReq: &http.Request{
Method: http.MethodGet,
URL: mustNewURL(t, "http://my-url.ch"),
Header: defaultFetchConfig.Headers,
Body: io.NopCloser(bytes.NewReader([]byte(`{"myData":"hello world"}`))),
},
},
{
name: "too many args",
args: args{
args: []goja.Value{
runtime.ToValue("http://my-url.ch"),
runtime.ToValue("http://my-url.ch"),
runtime.ToValue("http://my-url.ch"),
},
},
wantReq: nil,
shouldPanic: true,
},
{
name: "no args",
args: args{
args: []goja.Value{},
},
wantReq: nil,
shouldPanic: true,
},
{
name: "invalid config",
args: args{
args: []goja.Value{
runtime.ToValue("http://my-url.ch"),
runtime.ToValue(struct {
Invalid bool
}{
Invalid: true,
}),
},
},
wantReq: nil,
shouldPanic: true,
},
{
name: "invalid method",
args: args{
args: []goja.Value{
runtime.ToValue("http://my-url.ch"),
runtime.ToValue(struct {
Method string
}{
Method: " asdf asdf",
}),
},
},
wantReq: nil,
shouldPanic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
panicked := false
if tt.shouldPanic {
defer func() {
if panicked != tt.shouldPanic {
t.Errorf("wanted panic: %v got %v", tt.shouldPanic, panicked)
}
}()
defer func() {
recover()
panicked = true
}()
}
c := &HTTP{
runtime: runtime,
}
gotReq := c.buildHTTPRequest(context.Background(), tt.args.args)
if tt.shouldPanic {
return
}
if gotReq.URL.String() != tt.wantReq.URL.String() {
t.Errorf("url = %s, want %s", gotReq.URL, tt.wantReq.URL)
}
if !reflect.DeepEqual(gotReq.Header, tt.wantReq.Header) {
t.Errorf("headers = %v, want %v", gotReq.Header, tt.wantReq.Header)
}
if gotReq.Method != tt.wantReq.Method {
t.Errorf("method = %s, want %s", gotReq.Method, tt.wantReq.Method)
}
if tt.wantReq.Body == nil {
if gotReq.Body != nil {
t.Errorf("didn't expect a body")
}
return
}
gotBody, _ := io.ReadAll(gotReq.Body)
wantBody, _ := io.ReadAll(tt.wantReq.Body)
if !reflect.DeepEqual(gotBody, wantBody) {
t.Errorf("config.Body got = %s, want %s", gotBody, wantBody)
}
})
}
}

View File

@@ -0,0 +1,80 @@
package actions
import (
"context"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/console"
"github.com/sirupsen/logrus"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/record"
)
var (
logstoreService *logstore.Service[*record.ExecutionLog]
_ console.Printer = (*logger)(nil)
)
func SetLogstoreService(svc *logstore.Service[*record.ExecutionLog]) {
logstoreService = svc
}
type logger struct {
ctx context.Context
started time.Time
instanceID string
}
// newLogger returns a *logger instance that should only be used for a single action run.
// The first log call sets the started field for subsequent log calls
func newLogger(ctx context.Context, instanceID string) *logger {
return &logger{
ctx: ctx,
started: time.Time{},
instanceID: instanceID,
}
}
func (l *logger) Log(msg string) {
l.log(msg, logrus.InfoLevel, false)
}
func (l *logger) Warn(msg string) {
l.log(msg, logrus.WarnLevel, false)
}
func (l *logger) Error(msg string) {
l.log(msg, logrus.ErrorLevel, false)
}
func (l *logger) log(msg string, level logrus.Level, last bool) {
ts := time.Now()
if l.started.IsZero() {
l.started = ts
}
r := &record.ExecutionLog{
LogDate: ts,
InstanceID: l.instanceID,
Message: msg,
LogLevel: level,
}
if last {
r.Took = ts.Sub(l.started)
}
logstoreService.Handle(l.ctx, r)
}
func withLogger(ctx context.Context) Option {
instance := authz.GetInstance(ctx)
instanceID := instance.InstanceID()
return func(c *runConfig) {
c.logger = newLogger(ctx, instanceID)
c.instanceID = instanceID
c.modules["zitadel/log"] = func(runtime *goja.Runtime, module *goja.Object) {
console.RequireWithPrinter(c.logger)(runtime, module)
}
}
}

View File

@@ -0,0 +1,138 @@
package object
import (
"net"
"time"
"github.com/dop251/goja"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/domain"
)
// AuthRequestField accepts the domain.AuthRequest by value, so it's not mutated
func AuthRequestField(authRequest *domain.AuthRequest) func(c *actions.FieldConfig) interface{} {
return func(c *actions.FieldConfig) interface{} {
return AuthRequestFromDomain(c, authRequest)
}
}
func AuthRequestFromDomain(c *actions.FieldConfig, request *domain.AuthRequest) goja.Value {
if request == nil {
return c.Runtime.ToValue(nil)
}
var maxAuthAge *time.Duration
if request.MaxAuthAge != nil {
maxAuthAgeCopy := *request.MaxAuthAge
maxAuthAge = &maxAuthAgeCopy
}
return c.Runtime.ToValue(&authRequest{
Id: request.ID,
AgentId: request.AgentID,
CreationDate: request.CreationDate,
ChangeDate: request.ChangeDate,
BrowserInfo: browserInfoFromDomain(request.BrowserInfo),
ApplicationId: request.ApplicationID,
CallbackUri: request.CallbackURI,
TransferState: request.TransferState,
Prompt: request.Prompt,
UiLocales: request.UiLocales,
LoginHint: request.LoginHint,
MaxAuthAge: maxAuthAge,
InstanceId: request.InstanceID,
Request: requestFromDomain(request.Request),
UserId: request.UserID,
UserName: request.UserName,
LoginName: request.LoginName,
DisplayName: request.DisplayName,
ResourceOwner: request.UserOrgID,
RequestedOrgId: request.RequestedOrgID,
RequestedOrgName: request.RequestedOrgName,
RequestedPrimaryDomain: request.RequestedPrimaryDomain,
RequestedOrgDomain: request.RequestedOrgDomain,
ApplicationResourceOwner: request.ApplicationResourceOwner,
PrivateLabelingSetting: request.PrivateLabelingSetting,
SelectedIdpConfigId: request.SelectedIDPConfigID,
LinkingUsers: externalUsersFromDomain(request.LinkingUsers),
PasswordVerified: request.PasswordVerified,
MfasVerified: request.MFAsVerified,
Audience: request.Audience,
AuthTime: request.AuthTime,
})
}
type authRequest struct {
Id string
AgentId string
CreationDate time.Time
ChangeDate time.Time
BrowserInfo *browserInfo
ApplicationId string
CallbackUri string
TransferState string
Prompt []domain.Prompt
UiLocales []string
LoginHint string
MaxAuthAge *time.Duration
InstanceId string
Request *request
UserId string
UserName string
LoginName string
DisplayName string
// UserOrgID string
ResourceOwner string
// requested by scope
RequestedOrgId string
// requested by scope
RequestedOrgName string
// requested by scope
RequestedPrimaryDomain string
// requested by scope
RequestedOrgDomain bool
// client
ApplicationResourceOwner string
PrivateLabelingSetting domain.PrivateLabelingSetting
SelectedIdpConfigId string
LinkingUsers []*externalUser
PasswordVerified bool
MfasVerified []domain.MFAType
Audience []string
AuthTime time.Time
}
func browserInfoFromDomain(info *domain.BrowserInfo) *browserInfo {
if info == nil {
return nil
}
return &browserInfo{
UserAgent: info.UserAgent,
AcceptLanguage: info.AcceptLanguage,
RemoteIp: info.RemoteIP,
}
}
func requestFromDomain(req domain.Request) *request {
r := new(request)
if oidcRequest, ok := req.(*domain.AuthRequestOIDC); ok {
r.Oidc = OIDCRequest{Scopes: oidcRequest.Scopes}
}
return r
}
type request struct {
Oidc OIDCRequest
}
type OIDCRequest struct {
Scopes []string
}
type browserInfo struct {
UserAgent string
AcceptLanguage string
RemoteIp net.IP
}

View File

@@ -0,0 +1,44 @@
package object
import (
"net/http"
"github.com/zitadel/zitadel/internal/actions"
)
// HTTPRequestField accepts the http.Request by value, so it's not mutated
func HTTPRequestField(request *http.Request) func(c *actions.FieldConfig) interface{} {
return func(c *actions.FieldConfig) interface{} {
return c.Runtime.ToValue(&httpRequest{
Method: request.Method,
Url: request.URL.String(),
Proto: request.Proto,
ContentLength: request.ContentLength,
Host: request.Host,
Form: copyMap(request.Form),
PostForm: copyMap(request.PostForm),
RemoteAddr: request.RemoteAddr,
Headers: copyMap(request.Header),
})
}
}
type httpRequest struct {
Method string
Url string
Proto string
ContentLength int64
Host string
Form map[string][]string
PostForm map[string][]string
RemoteAddr string
Headers map[string][]string
}
func copyMap(src map[string][]string) map[string][]string {
dst := make(map[string][]string)
for k, v := range src {
dst[k] = v
}
return dst
}

View File

@@ -0,0 +1,234 @@
package object
import (
"context"
"encoding/json"
"time"
"github.com/dop251/goja"
"github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func OrgMetadataListFromQuery(c *actions.FieldConfig, orgMetadata *query.OrgMetadataList) goja.Value {
result := &metadataList{
Count: orgMetadata.Count,
Sequence: orgMetadata.Sequence,
Timestamp: orgMetadata.LastRun,
Metadata: make([]*metadata, len(orgMetadata.Metadata)),
}
for i, md := range orgMetadata.Metadata {
result.Metadata[i] = &metadata{
CreationDate: md.CreationDate,
ChangeDate: md.ChangeDate,
ResourceOwner: md.ResourceOwner,
Sequence: md.Sequence,
Key: md.Key,
Value: metadataByteArrayToValue(md.Value, c.Runtime),
}
}
return c.Runtime.ToValue(result)
}
func UserMetadataListFromQuery(c *actions.FieldConfig, metadata *query.UserMetadataList) goja.Value {
result := &userMetadataList{
Count: metadata.Count,
Sequence: metadata.Sequence,
Timestamp: metadata.LastRun,
Metadata: make([]*userMetadata, len(metadata.Metadata)),
}
for i, md := range metadata.Metadata {
result.Metadata[i] = &userMetadata{
CreationDate: md.CreationDate,
ChangeDate: md.ChangeDate,
ResourceOwner: md.ResourceOwner,
Sequence: md.Sequence,
Key: md.Key,
Value: metadataByteArrayToValue(md.Value, c.Runtime),
}
}
return c.Runtime.ToValue(result)
}
func UserMetadataListFromSlice(c *actions.FieldConfig, metadata []query.UserMetadata) goja.Value {
result := &userMetadataList{
// Count was the only field ever queried from the DB in the old implementation,
// so Sequence and LastRun are omitted.
Count: uint64(len(metadata)),
Metadata: make([]*userMetadata, len(metadata)),
}
for i, md := range metadata {
result.Metadata[i] = &userMetadata{
CreationDate: md.CreationDate,
ChangeDate: md.ChangeDate,
ResourceOwner: md.ResourceOwner,
Sequence: md.Sequence,
Key: md.Key,
Value: metadataByteArrayToValue(md.Value, c.Runtime),
}
}
return c.Runtime.ToValue(result)
}
func GetOrganizationMetadata(ctx context.Context, queries *query.Queries, c *actions.FieldConfig, organizationID string) goja.Value {
metadata, err := queries.SearchOrgMetadata(
ctx,
true,
organizationID,
&query.OrgMetadataSearchQueries{},
false,
)
if err != nil {
logging.WithError(err).Info("unable to get org metadata in action")
panic(err)
}
return OrgMetadataListFromQuery(c, metadata)
}
func metadataByteArrayToValue(val []byte, runtime *goja.Runtime) goja.Value {
var value interface{}
if !json.Valid(val) {
var err error
val, err = json.Marshal(string(val))
if err != nil {
logging.WithError(err).Debug("unable to marshal unknown value")
panic(err)
}
}
err := json.Unmarshal(val, &value)
if err != nil {
logging.WithError(err).Debug("unable to unmarshal into map")
panic(err)
}
return runtime.ToValue(value)
}
type metadataList struct {
Count uint64
Sequence uint64
Timestamp time.Time
Metadata []*metadata
}
type metadata struct {
CreationDate time.Time
ChangeDate time.Time
ResourceOwner string
Sequence uint64
Key string
Value goja.Value
}
type userMetadataList struct {
Count uint64
Sequence uint64
Timestamp time.Time
Metadata []*userMetadata
}
type userMetadata struct {
CreationDate time.Time
ChangeDate time.Time
ResourceOwner string
Sequence uint64
Key string
Value goja.Value
}
type MetadataList struct {
metadata []*Metadata
}
type Metadata struct {
Key string
// Value is for exporting to javascript
Value goja.Value
// value is for mapping to [domain.Metadata]
value []byte
}
func (md *MetadataList) AppendMetadataFunc(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 2 {
panic("exactly 2 (key, value) arguments expected")
}
value, err := json.Marshal(call.Arguments[1].Export())
if err != nil {
logging.WithError(err).Debug("unable to marshal")
panic(err)
}
md.metadata = append(md.metadata,
&Metadata{
Key: call.Arguments[0].Export().(string),
Value: call.Arguments[1],
value: value,
})
return nil
}
func (md *MetadataList) MetadataListFromDomain(runtime *goja.Runtime) interface{} {
for i, metadata := range md.metadata {
md.metadata[i].Value = metadataByteArrayToValue(metadata.value, runtime)
}
return &md.metadata
}
func MetadataListFromDomain(metadata []*domain.Metadata) *MetadataList {
list := &MetadataList{metadata: make([]*Metadata, len(metadata))}
for i, md := range metadata {
list.metadata[i] = &Metadata{
Key: md.Key,
value: md.Value,
}
}
return list
}
func MetadataListToDomain(metadataList *MetadataList) []*domain.Metadata {
if metadataList == nil {
return nil
}
list := make([]*domain.Metadata, len(metadataList.metadata))
for i, metadata := range metadataList.metadata {
value := metadata.value
if len(value) == 0 {
value = mapBytesToByteArray(metadata.Value.Export())
}
list[i] = &domain.Metadata{
Key: metadata.Key,
Value: value,
}
}
return list
}
// mapBytesToByteArray is used for backwards compatibility of old metadata.push method
// converts the Javascript uint8 array which is exported as []interface{} to a []byte
func mapBytesToByteArray(i interface{}) []byte {
bytes, ok := i.([]interface{})
if !ok {
return nil
}
value := make([]byte, len(bytes))
for i, val := range bytes {
b, ok := val.(int64)
if !ok {
return nil
}
value[i] = byte(b)
}
return value
}

View File

@@ -0,0 +1,14 @@
package object
import "github.com/dop251/goja"
func objectFromFirstArgument(call goja.FunctionCall, runtime *goja.Runtime) *goja.Object {
if len(call.Arguments) != 1 {
panic("exactly one argument expected")
}
object := call.Arguments[0].ToObject(runtime)
if object == nil {
panic("unable to unmarshal arg")
}
return object
}

View File

@@ -0,0 +1,181 @@
package object
import (
"time"
"github.com/dop251/goja"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
func UserFromExternalUser(c *actions.FieldConfig, user *domain.ExternalUser) goja.Value {
return c.Runtime.ToValue(externalUserFromDomain(user))
}
func externalUsersFromDomain(users []*domain.ExternalUser) []*externalUser {
externalUsers := make([]*externalUser, len(users))
for i, user := range users {
externalUsers[i] = externalUserFromDomain(user)
}
return externalUsers
}
func externalUserFromDomain(user *domain.ExternalUser) *externalUser {
return &externalUser{
ExternalId: user.ExternalUserID,
ExternalIdpId: user.IDPConfigID,
Human: human{
FirstName: user.FirstName,
LastName: user.LastName,
NickName: user.NickName,
DisplayName: user.DisplayName,
PreferredLanguage: user.PreferredLanguage.String(),
Email: user.Email,
IsEmailVerified: user.IsEmailVerified,
Phone: user.Phone,
IsPhoneVerified: user.IsPhoneVerified,
},
}
}
func UserFromHuman(c *actions.FieldConfig, user *domain.Human) goja.Value {
u := &humanUser{
Id: user.AggregateID,
CreationDate: user.CreationDate,
ChangeDate: user.ChangeDate,
ResourceOwner: user.ResourceOwner,
Sequence: user.Sequence,
State: user.State,
Username: user.Username,
LoginNames: user.LoginNames,
PreferredLoginName: user.PreferredLoginName,
}
if user.Profile != nil {
u.Human.FirstName = user.Profile.FirstName
u.Human.LastName = user.Profile.LastName
u.Human.NickName = user.Profile.NickName
u.Human.DisplayName = user.Profile.DisplayName
u.Human.PreferredLanguage = user.Profile.PreferredLanguage.String()
}
if user.Email != nil {
u.Human.Email = user.Email.EmailAddress
u.Human.IsEmailVerified = user.Email.IsEmailVerified
}
if user.Phone != nil {
u.Human.Phone = user.Phone.PhoneNumber
u.Human.IsPhoneVerified = user.Phone.IsPhoneVerified
}
return c.Runtime.ToValue(u)
}
func UserFromQuery(c *actions.FieldConfig, user *query.User) goja.Value {
if user.Human != nil {
return humanFromQuery(c, user)
}
return machineFromQuery(c, user)
}
func humanFromQuery(c *actions.FieldConfig, user *query.User) goja.Value {
return c.Runtime.ToValue(&humanUser{
Id: user.ID,
CreationDate: user.CreationDate,
ChangeDate: user.ChangeDate,
ResourceOwner: user.ResourceOwner,
Sequence: user.Sequence,
State: user.State,
Username: user.Username,
LoginNames: user.LoginNames,
PreferredLoginName: user.PreferredLoginName,
Human: human{
FirstName: user.Human.FirstName,
LastName: user.Human.LastName,
NickName: user.Human.NickName,
DisplayName: user.Human.DisplayName,
AvatarKey: user.Human.AvatarKey,
PreferredLanguage: user.Human.PreferredLanguage.String(),
Gender: user.Human.Gender,
Email: user.Human.Email,
IsEmailVerified: user.Human.IsEmailVerified,
Phone: user.Human.Phone,
IsPhoneVerified: user.Human.IsPhoneVerified,
},
})
}
func machineFromQuery(c *actions.FieldConfig, user *query.User) goja.Value {
return c.Runtime.ToValue(&machineUser{
Id: user.ID,
CreationDate: user.CreationDate,
ChangeDate: user.ChangeDate,
ResourceOwner: user.ResourceOwner,
Sequence: user.Sequence,
State: user.State,
Username: user.Username,
LoginNames: user.LoginNames,
PreferredLoginName: user.PreferredLoginName,
Machine: machine{
Name: user.Machine.Name,
Description: user.Machine.Description,
},
})
}
type externalUser struct {
ExternalId string
ExternalIdpId string
Human human
}
type humanUser struct {
Id string
CreationDate time.Time
ChangeDate time.Time
ResourceOwner string
Sequence uint64
State domain.UserState
Username string
LoginNames database.TextArray[string]
PreferredLoginName string
Human human
}
type human struct {
FirstName string
LastName string
NickName string
DisplayName string
AvatarKey string
PreferredLanguage string
Gender domain.Gender
Email domain.EmailAddress
IsEmailVerified bool
Phone domain.PhoneNumber
IsPhoneVerified bool
}
type machineUser struct {
Id string
CreationDate time.Time
ChangeDate time.Time
ResourceOwner string
Sequence uint64
State domain.UserState
Username string
LoginNames database.TextArray[string]
PreferredLoginName string
Machine machine
}
type machine struct {
Name string
Description string
}

View File

@@ -0,0 +1,178 @@
package object
import (
"context"
"time"
"github.com/dop251/goja"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query"
)
type UserGrants struct {
UserGrants []UserGrant
}
type UserGrant struct {
ProjectID string
ProjectGrantID string
Roles []string
}
type userGrantList struct {
Count uint64
Sequence uint64
Timestamp time.Time
Grants []*userGrant
}
type userGrant struct {
Id string
ProjectGrantId string
State domain.UserGrantState
UserGrantResourceOwner string
UserGrantResourceOwnerName string
CreationDate time.Time
ChangeDate time.Time
Sequence uint64
UserId string
UserResourceOwner string
Roles []string
ProjectId string
ProjectName string
GetOrgMetadata func(goja.FunctionCall) goja.Value
}
func AppendGrantFunc(userGrants *UserGrants) func(c *actions.FieldConfig) func(call goja.FunctionCall) goja.Value {
return func(c *actions.FieldConfig) func(call goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
firstArg := objectFromFirstArgument(call, c.Runtime)
grant := UserGrant{}
mapObjectToGrant(firstArg, &grant)
userGrants.UserGrants = append(userGrants.UserGrants, grant)
return nil
}
}
}
func UserGrantsFromQuery(ctx context.Context, queries *query.Queries, c *actions.FieldConfig, userGrants *query.UserGrants) goja.Value {
if userGrants == nil {
return c.Runtime.ToValue(nil)
}
orgMetadata := make(map[string]goja.Value)
grantList := &userGrantList{
Count: userGrants.Count,
Sequence: userGrants.Sequence,
Timestamp: userGrants.LastRun,
Grants: make([]*userGrant, len(userGrants.UserGrants)),
}
for i, grant := range userGrants.UserGrants {
grantList.Grants[i] = &userGrant{
Id: grant.ID,
ProjectGrantId: grant.GrantID,
State: grant.State,
CreationDate: grant.CreationDate,
ChangeDate: grant.ChangeDate,
Sequence: grant.Sequence,
UserId: grant.UserID,
Roles: grant.Roles,
UserResourceOwner: grant.UserResourceOwner,
UserGrantResourceOwner: grant.ResourceOwner,
UserGrantResourceOwnerName: grant.OrgName,
ProjectId: grant.ProjectID,
ProjectName: grant.ProjectName,
GetOrgMetadata: func(call goja.FunctionCall) goja.Value {
if md, ok := orgMetadata[grant.ResourceOwner]; ok {
return md
}
orgMetadata[grant.ResourceOwner] = GetOrganizationMetadata(ctx, queries, c, grant.ResourceOwner)
return orgMetadata[grant.ResourceOwner]
},
}
}
return c.Runtime.ToValue(grantList)
}
func UserGrantsFromSlice(ctx context.Context, queries *query.Queries, c *actions.FieldConfig, userGrants []query.UserGrant) goja.Value {
if userGrants == nil {
return c.Runtime.ToValue(nil)
}
orgMetadata := make(map[string]goja.Value)
grantList := &userGrantList{
Count: uint64(len(userGrants)),
Grants: make([]*userGrant, len(userGrants)),
}
for i, grant := range userGrants {
grantList.Grants[i] = &userGrant{
Id: grant.ID,
ProjectGrantId: grant.GrantID,
State: grant.State,
CreationDate: grant.CreationDate,
ChangeDate: grant.ChangeDate,
Sequence: grant.Sequence,
UserId: grant.UserID,
Roles: grant.Roles,
UserResourceOwner: grant.UserResourceOwner,
UserGrantResourceOwner: grant.ResourceOwner,
UserGrantResourceOwnerName: grant.OrgName,
ProjectId: grant.ProjectID,
ProjectName: grant.ProjectName,
GetOrgMetadata: func(goja.FunctionCall) goja.Value {
if md, ok := orgMetadata[grant.ResourceOwner]; ok {
return md
}
orgMetadata[grant.ResourceOwner] = GetOrganizationMetadata(ctx, queries, c, grant.ResourceOwner)
return orgMetadata[grant.ResourceOwner]
},
}
}
return c.Runtime.ToValue(grantList)
}
func UserGrantsToDomain(userID string, actionUserGrants []UserGrant) []*domain.UserGrant {
if actionUserGrants == nil {
return nil
}
userGrants := make([]*domain.UserGrant, len(actionUserGrants))
for i, grant := range actionUserGrants {
userGrants[i] = &domain.UserGrant{
UserID: userID,
ProjectID: grant.ProjectID,
ProjectGrantID: grant.ProjectGrantID,
RoleKeys: grant.Roles,
}
}
return userGrants
}
func mapObjectToGrant(object *goja.Object, grant *UserGrant) {
for _, key := range object.Keys() {
switch key {
case "projectId", "projectID":
grant.ProjectID = object.Get(key).String()
case "projectGrantId", "projectGrantID":
grant.ProjectGrantID = object.Get(key).String()
case "roles":
if roles, ok := object.Get(key).Export().([]interface{}); ok {
for _, role := range roles {
if r, ok := role.(string); ok {
grant.Roles = append(grant.Roles, r)
}
}
}
}
}
if grant.ProjectID == "" {
panic("projectId not set")
}
}

View File

@@ -0,0 +1,83 @@
package actions
import (
"context"
"github.com/dop251/goja"
"github.com/google/uuid"
"github.com/zitadel/logging"
)
func WithUUID(ctx context.Context) Option {
return func(c *runConfig) {
c.modules["zitadel/uuid"] = func(runtime *goja.Runtime, module *goja.Object) {
requireUUID(ctx, runtime, module)
}
}
}
func requireUUID(_ context.Context, runtime *goja.Runtime, module *goja.Object) {
o := module.Get("exports").(*goja.Object)
logging.OnError(o.Set("v1", inRuntime(uuid.NewUUID, runtime))).Warn("unable to set module")
logging.OnError(o.Set("v3", inRuntimeHash(uuid.NewMD5, runtime))).Warn("unable to set module")
logging.OnError(o.Set("v4", inRuntime(uuid.NewRandom, runtime))).Warn("unable to set module")
logging.OnError(o.Set("v5", inRuntimeHash(uuid.NewSHA1, runtime))).Warn("unable to set module")
logging.OnError(o.Set("namespaceDNS", uuid.NameSpaceDNS)).Warn("unable to set namespace")
logging.OnError(o.Set("namespaceURL", uuid.NameSpaceURL)).Warn("unable to set namespace")
logging.OnError(o.Set("namespaceOID", uuid.NameSpaceOID)).Warn("unable to set namespace")
logging.OnError(o.Set("namespaceX500", uuid.NameSpaceX500)).Warn("unable to set namespace")
}
func inRuntime(function func() (uuid.UUID, error), runtime *goja.Runtime) func(call goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 0 {
panic("invalid arg count")
}
uuid, err := function()
if err != nil {
logging.WithError(err)
panic(err)
}
return runtime.ToValue(uuid.String())
}
}
func inRuntimeHash(function func(uuid.UUID, []byte) uuid.UUID, runtime *goja.Runtime) func(call goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 2 {
logging.WithFields("count", len(call.Arguments)).Debug("other than 2 args provided")
panic("invalid arg count")
}
var err error
var namespace uuid.UUID
switch n := call.Arguments[0].Export().(type) {
case string:
namespace, err = uuid.Parse(n)
if err != nil {
logging.WithError(err).Debug("namespace failed parsing as UUID")
panic(err)
}
case uuid.UUID:
namespace = n
default:
logging.WithError(err).Debug("invalid type for namespace")
panic(err)
}
var data []byte
switch d := call.Arguments[1].Export().(type) {
case string:
data = []byte(d)
case []byte:
data = d
default:
logging.WithError(err).Debug("invalid type for data")
panic(err)
}
return runtime.ToValue(function(namespace, data).String())
}
}