Skip to content

Commit df8ed71

Browse files
authored
[NOJIRA] Validation metrics support + return issuer from validator (#71)
1 parent 615ec9f commit df8ed71

File tree

6 files changed

+261
-14
lines changed

6 files changed

+261
-14
lines changed

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
)
1313

1414
require (
15+
github.com/DataDog/datadog-go/v5 v5.8.1 // indirect
1516
github.com/davecgh/go-spew v1.1.1 // indirect
1617
github.com/labstack/gommon v0.4.2 // indirect
1718
github.com/mattn/go-colorable v0.1.13 // indirect
@@ -20,6 +21,8 @@ require (
2021
github.com/stretchr/objx v0.5.2 // indirect
2122
github.com/valyala/bytebufferpool v1.0.0 // indirect
2223
github.com/valyala/fasttemplate v1.2.2 // indirect
24+
go.uber.org/multierr v1.10.0 // indirect
25+
go.uber.org/zap v1.27.0 // indirect
2326
golang.org/x/crypto v0.39.0 // indirect
2427
golang.org/x/net v0.38.0 // indirect
2528
golang.org/x/sys v0.33.0 // indirect

go.sum

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
github.com/DataDog/datadog-go/v5 v5.8.1 h1:+GOES5W9zpKlhwHptZVW2C0NLVf7ilr7pHkDcbNvpIc=
2+
github.com/DataDog/datadog-go/v5 v5.8.1/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw=
3+
github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
14
github.com/bitrise-io/go-auth0 v0.0.0-20250924051602-0e08ce7ce456 h1:3LESTMNySlRrTjn0aZhmDIy1xmzf0uOgX5DU9jYXc7s=
25
github.com/bitrise-io/go-auth0 v0.0.0-20250924051602-0e08ce7ce456/go.mod h1:Lre6GilgdYuCkd8hOSUjTQn28Ar8Y6IZH8W/+RyVvLs=
36
github.com/bitrise-io/go-auth0 v0.0.0-20250924125910-31ce4e32c32b h1:lypuksW0qioSYLhxlsgk/AS4vsHF05EKE9KejzpVcG4=
47
github.com/bitrise-io/go-auth0 v0.0.0-20250924125910-31ce4e32c32b/go.mod h1:Lre6GilgdYuCkd8hOSUjTQn28Ar8Y6IZH8W/+RyVvLs=
8+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
59
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
610
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
711
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
812
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
13+
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
914
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
1015
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
1116
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
@@ -21,27 +26,65 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
2126
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
2227
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2328
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
29+
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
30+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
31+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
32+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
2433
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
2534
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
35+
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
36+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
37+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
38+
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
2639
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
2740
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
2841
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
2942
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
3043
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
3144
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
45+
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
46+
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
47+
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
48+
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
49+
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
50+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
51+
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
3252
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
3353
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
54+
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
55+
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
56+
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
57+
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
3458
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
3559
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
3660
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
3761
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
62+
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
63+
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
64+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
65+
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
66+
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
67+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
68+
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
69+
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
70+
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
3871
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
3972
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
4073
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
4174
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
75+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
76+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
77+
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
4278
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
4379
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
80+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
81+
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
82+
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
83+
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
84+
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
85+
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
4486
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
4587
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
88+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
4689
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
4790
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

metrics/datadog.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package metrics
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/DataDog/datadog-go/v5/statsd"
7+
"github.com/pkg/errors"
8+
"go.uber.org/zap"
9+
)
10+
11+
const unknownIssuerId = "unknown"
12+
13+
type DatadogMetrics struct {
14+
rawClient statsd.ClientInterface
15+
logger Logger
16+
}
17+
18+
// nolint: govet
19+
type DatadogConfig struct {
20+
StatsdHost string `env:"STATSD_HOST,default=datadog"`
21+
StatsdPort string `env:"STATSD_PORT,default=8125"`
22+
MetricsEnabled bool `env:"METRICS_ENABLED,default=true"`
23+
}
24+
25+
type Logger interface {
26+
Errorw(msg string, keysAndValues ...interface{})
27+
}
28+
29+
func NewDatadogMetrics(cfg DatadogConfig, logger Logger) (*DatadogMetrics, error) {
30+
if !cfg.MetricsEnabled {
31+
return &DatadogMetrics{
32+
rawClient: &statsd.NoOpClient{},
33+
logger: logger,
34+
}, nil
35+
}
36+
37+
c, err := statsd.New(fmt.Sprintf("%s:%s", cfg.StatsdHost, cfg.StatsdPort))
38+
if err != nil {
39+
return &DatadogMetrics{}, errors.Wrap(err, "create statsd client failed")
40+
}
41+
42+
return &DatadogMetrics{
43+
rawClient: c,
44+
logger: logger,
45+
}, nil
46+
}
47+
48+
func (dm *DatadogMetrics) IncrRaw(name string, tags []string, rate float64) {
49+
if name == "" {
50+
dm.logger.Errorw("metric name is empty")
51+
52+
return
53+
}
54+
55+
if err := dm.rawClient.Incr(name, tags, rate); err != nil {
56+
dm.logger.Errorw("failed to increment raw metric", zap.String("metric", name), zap.Error(err))
57+
}
58+
}
59+
60+
func (dm *DatadogMetrics) IncrAuthValidationSucceededMetric(issuer string) {
61+
if issuer == "" {
62+
issuer = unknownIssuerId
63+
}
64+
65+
dm.IncrRaw("bitrise.jwt_auth.validation_succeeded", []string{"iss:" + issuer}, 1)
66+
}
67+
68+
func (dm *DatadogMetrics) IncrAuthValidationFailedMetric(issuer string) {
69+
if issuer == "" {
70+
issuer = unknownIssuerId
71+
}
72+
73+
dm.IncrRaw("bitrise.jwt_auth.validation_failed", []string{"iss:" + issuer}, 1)
74+
}
75+
76+
func (dm *DatadogMetrics) Close() error {
77+
return dm.rawClient.Close()
78+
}

service/mocks/jwtvalidatorrepository_mock.go

Lines changed: 120 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

service/validator_repository.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ import (
1414
//
1515
// The request must contain a valid JWT in the Authorization header ("Authorization: Bearer <token>")
1616
// The validator is selected based on the "iss" claim in the JWT
17+
//go:generate moq -out mocks/jwtvalidatorrepository_mock.go -pkg service_test . JwtValidatorRepository
1718
type JwtValidatorRepository interface {
18-
GetJwtValidatorForRequest(r *http.Request) (Validator, error)
19-
GetJwtValidatorForRawToken(rawJwt string) (Validator, error)
19+
// GetJwtValidatorForRequest returns the JWT validator and its issuer for the given request
20+
GetJwtValidatorForRequest(r *http.Request) (Validator, string, error)
21+
// GetJwtValidatorForRawToken returns the JWT validator and its issuer for the given raw JWT
22+
GetJwtValidatorForRawToken(rawJwt string) (Validator, string, error)
2023
}
2124

2225
// DefaultJwtValidatorRepository ...
@@ -32,28 +35,28 @@ func NewJwtValidatorRepository(jwtValidators map[string]Validator) JwtValidatorR
3235
}
3336

3437
// GetJwtValidatorForRequest ...
35-
func (vr *DefaultJwtValidatorRepository) GetJwtValidatorForRequest(r *http.Request) (Validator, error) {
38+
func (vr *DefaultJwtValidatorRepository) GetJwtValidatorForRequest(r *http.Request) (Validator, string, error) {
3639
rawJwt := strings.Split(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ")
3740
if len(rawJwt) != 2 {
38-
return nil, errors.New("failed to read JWT from header")
41+
return nil, "", errors.New("failed to read JWT from header")
3942
}
4043

4144
return vr.GetJwtValidatorForRawToken(rawJwt[1])
4245
}
4346

4447
// GetJwtValidatorForRawToken ...
45-
func (vr *DefaultJwtValidatorRepository) GetJwtValidatorForRawToken(rawJwt string) (Validator, error) {
48+
func (vr *DefaultJwtValidatorRepository) GetJwtValidatorForRawToken(rawJwt string) (Validator, string, error) {
4649
iss, err := vr.getIssuerFromRawJWT(rawJwt)
4750
if err != nil {
48-
return nil, errors.Wrap(err, "failed to get issuer form the JWT")
51+
return nil, "", errors.Wrap(err, "failed to get issuer form the JWT")
4952
}
5053

5154
validator := vr.JwtValidators[iss]
5255
if validator == nil {
53-
return nil, fmt.Errorf("there is no JWT validator for issuer: %s", iss)
56+
return nil, iss, fmt.Errorf("there is no JWT validator for issuer: %s", iss)
5457
}
5558

56-
return validator, nil
59+
return validator, iss, nil
5760
}
5861

5962
func (vr *DefaultJwtValidatorRepository) getIssuerFromRawJWT(rawJwt string) (string, error) {

service/validator_repository_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ func Test_GetJwtValidatorForRawToken_GivenMatchingValidatorExists_ReturnsValidat
2727
tokenIssuerServiceIssuer: tokenIssuerServiceValidator,
2828
})
2929

30-
v, err := vr.GetJwtValidatorForRawToken(mocks.RawMockToken)
30+
v, iss, err := vr.GetJwtValidatorForRawToken(mocks.RawMockToken)
3131
assert.NoError(t, err)
32-
32+
assert.Equal(t, tokenIssuerServiceIssuer, iss)
3333
assert.Equal(t, tokenIssuerServiceValidator, v)
3434
}
3535

@@ -49,9 +49,9 @@ func Test_GetJwtValidatorForRequest_GivenMatchingValidatorExists_ReturnsValidato
4949
assert.NoError(t, err)
5050
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", mocks.RawMockToken))
5151

52-
v, err := vr.GetJwtValidatorForRequest(request)
52+
v, iss, err := vr.GetJwtValidatorForRequest(request)
5353
assert.NoError(t, err)
54-
54+
assert.Equal(t, tokenIssuerServiceIssuer, iss)
5555
assert.Equal(t, tokenIssuerServiceValidator, v)
5656
}
5757

@@ -67,7 +67,7 @@ func Test_GetJwtValidatorForRequest_GivenNoMatchingValidatorExists_ReturnsError(
6767
assert.NoError(t, err)
6868
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", mocks.RawMockToken))
6969

70-
_, err = vr.GetJwtValidatorForRequest(request)
70+
_, _, err = vr.GetJwtValidatorForRequest(request)
7171
assert.EqualError(t, err, "there is no JWT validator for issuer: https://token-issuer.bitrise.io/auth/realms/bitrise-services")
7272
}
7373

@@ -83,6 +83,6 @@ func Test_GetJwtValidatorForRequest_GivenInvalidAuthorizationHeader_ReturnsError
8383
assert.NoError(t, err)
8484
request.Header.Add("Authorization", "InvalidHeader")
8585

86-
_, err = vr.GetJwtValidatorForRequest(request)
86+
_, _, err = vr.GetJwtValidatorForRequest(request)
8787
assert.EqualError(t, err, "failed to read JWT from header")
8888
}

0 commit comments

Comments
 (0)