diff --git a/changelog/unreleased/add-cli-user-management.md b/changelog/unreleased/add-cli-user-management.md new file mode 100644 index 0000000000..f86123aa6c --- /dev/null +++ b/changelog/unreleased/add-cli-user-management.md @@ -0,0 +1,17 @@ +Enhancement: Add early version of cli tools for user-management + +Following commands are available: + +list, ls List existing accounts +add, create, Create a new account +update Make changes to an existing account +remove, rm Removes an existing account +inspect Show detailed data on an existing account + +See --help for details. + +Note that not all account-attributes have an effect yet. This is due to ocis +being in an early development stage. + +https://github.com/owncloud/ocis-accounts/pull/69 +https://github.com/owncloud/product/issues/115 diff --git a/go.mod b/go.mod index 52969e8ad0..3b07f1f352 100644 --- a/go.mod +++ b/go.mod @@ -19,22 +19,24 @@ require ( github.com/golang/protobuf v1.4.2 github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect github.com/jmhodges/levigo v1.0.0 // indirect + github.com/mennanov/fieldmask-utils v0.3.2 github.com/micro/cli/v2 v2.1.2 github.com/micro/go-micro/v2 v2.6.0 - github.com/micro/protoc-gen-micro/v2 v2.3.0 // indirect github.com/oklog/run v1.1.0 + github.com/olekukonko/tablewriter v0.0.4 github.com/owncloud/ocis-pkg/v2 v2.2.2-0.20200602070144-cd0620668170 github.com/owncloud/ocis-settings v0.0.0-20200522101320-46ea31026363 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect github.com/restic/calens v0.2.0 + github.com/rs/zerolog v1.18.0 github.com/spf13/viper v1.7.0 github.com/stretchr/testify v1.4.0 github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c // indirect github.com/tredoe/osutil v1.0.5 golang.org/x/net v0.0.0-20200301022130-244492dfa37a - google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 + golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6 // indirect + google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece google.golang.org/protobuf v1.25.0 - gopkg.in/go-playground/assert.v1 v1.2.1 honnef.co/go/tools v0.0.1-2020.1.4 // indirect ) diff --git a/go.sum b/go.sum index 724cc74274..ae7e325bb3 100644 --- a/go.sum +++ b/go.sum @@ -175,13 +175,10 @@ github.com/cespare/xxhash/v2 v2.1.0 h1:yTUvW7Vhb89inJ+8irsUqiWjh8iT6sQPZiQzI6ReG github.com/cespare/xxhash/v2 v2.1.0/go.mod h1:dgIUBU3pDso/gPgZ1osOZ0iQf77oPR28Tjxl5dIMyVM= github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= github.com/cheggaaa/pb v1.0.28/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/cloudflare/cloudflare-go v0.10.2/go.mod h1:qhVI5MKwBGhdNU89ZRz2plgYutcJ5PCekLxXn56w6SY= github.com/cloudflare/cloudflare-go v0.10.6/go.mod h1:dcRl7AXBH5Bf7QFTBVc3TRzwvotSeO4AlnMhuxORAX8= -github.com/cloudflare/cloudflare-go v0.10.9/go.mod h1:5TrsWH+3f4NV6WjtS5QFp+DifH81rph40gU374Sh0dQ= github.com/containerd/cgroups v0.0.0-20190919134610-bf292b21730f/go.mod h1:OApqhQ4XNSNC13gXIwDjhOQxjWa/NxkwZXJ1EvqT0ko= github.com/containerd/console v0.0.0-20180822173158-c12b1e7919c1/go.mod h1:Tj/on1eG8kiEhd0+fhSDzsPAFESxzBBvdyEgyryXffw= github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= @@ -255,7 +252,6 @@ github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= -github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= @@ -597,18 +593,19 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54= github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-tty v0.0.0-20180219170247-931426f7535a/go.mod h1:XPvLUNfbS4fJH25nqRHfWLMa1ONC8Amw+mIA639KxkE= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mennanov/fieldmask-utils v0.3.2 h1:AkHXYBEOoyvocl8YhzoStATRnto5OH1PY4Rj78I5Cuc= +github.com/mennanov/fieldmask-utils v0.3.2/go.mod h1:JpaanSp6Ql5A8dGktEFxTmA9uBXmz3F+2LAXDZwiimU= github.com/mholt/certmagic v0.7.5/go.mod h1:91uJzK5K8IWtYQqTi5R2tsxV1pCde+wdGfaRaOZi6aQ= github.com/mholt/certmagic v0.8.3/go.mod h1:91uJzK5K8IWtYQqTi5R2tsxV1pCde+wdGfaRaOZi6aQ= github.com/mholt/certmagic v0.9.1/go.mod h1:nu8jbsbtwK4205EDH/ZUMTKsfYpJA1Q7MKXHfgTihNw= @@ -623,7 +620,6 @@ github.com/micro/go-micro v1.17.1/go.mod h1:klwUJL1gkdY1MHFyz+fFJXn52dKcty4hoe95 github.com/micro/go-micro v1.18.0 h1:gP70EZVHpJuUIT0YWth192JmlIci+qMOEByHm83XE9E= github.com/micro/go-micro v1.18.0/go.mod h1:klwUJL1gkdY1MHFyz+fFJXn52dKcty4hoe95Mp571AA= github.com/micro/go-micro/v2 v2.0.0/go.mod h1:v7QP5UhKRt37ixjJe8DouWmg0/eE6dltr5h0idJ9BpE= -github.com/micro/go-micro/v2 v2.5.1-0.20200417165434-16db76bee2fb/go.mod h1:qz2UT4UFdFVs+qUGMuDK3xuHgude1BgntqQ29sbpPlE= github.com/micro/go-micro/v2 v2.6.0 h1:HH6uEqTu6pkBtAlwAqQW2sf33640iEa1s9puGIctpO0= github.com/micro/go-micro/v2 v2.6.0/go.mod h1:60HMKlDN4ShZDJRrlgdcAmkCWNhQbYv+CDG3r7iLE34= github.com/micro/go-plugins v1.5.1 h1:swcFD7ynCTUo98APqIEIbPu2XMd6yVGTnI8PqdnCwOQ= @@ -633,12 +629,8 @@ github.com/micro/go-plugins/wrapper/trace/opencensus/v2 v2.0.1/go.mod h1:QrkcwcD github.com/micro/mdns v0.3.0/go.mod h1:KJ0dW7KmicXU2BV++qkLlmHYcVv7/hHnbtguSWt9Aoc= github.com/micro/micro v1.16.0 h1:qCZV20WoTOtJ1IyLU/a0A0BMSertfu+iOj/2AJ4Uvrk= github.com/micro/micro v1.16.0/go.mod h1:TO5Ng0KidbfRYIxVM4Q3deZ0A+qwRyP9WeXp+k2fWNA= -github.com/micro/micro/v2 v2.5.1-0.20200418121137-24e9b206767c h1:0xGuo2yepDL8p+id/kXqVka+5iiOBSyfqOX01csnOYk= -github.com/micro/micro/v2 v2.5.1-0.20200418121137-24e9b206767c/go.mod h1:fqqaYbJGYzSBi7Ms2Adly7Xzw9+WIRBAucUjwGmYeFY= github.com/micro/protoc-gen-micro v1.0.0 h1:qKh5S3I1RfenhIs5mqDFJLwRlRDlgin7XWiUKZbpwLM= github.com/micro/protoc-gen-micro v1.0.0/go.mod h1:C8ij4DJhapBmypcT00AXdb0cZ675/3PqUO02buWWqbE= -github.com/micro/protoc-gen-micro/v2 v2.3.0 h1:PBbGeNh4BOy1w4eRdeo4yWJJNWGLnaJX6/h55I74EXE= -github.com/micro/protoc-gen-micro/v2 v2.3.0/go.mod h1:gcsUvKSTTTalq+pqdUbFS40OTsURpYgL5+yUguR1djk= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.3/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.15/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= @@ -704,7 +696,6 @@ github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/nats-io/stan.go v0.5.0/go.mod h1:dYqB+vMN3C2F9pT1FRQpg9eHbjPj6mP0yYuyBNuXHZE= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= -github.com/netdata/go-orchestrator v0.0.0-20190905093727-c793edba0e8f/go.mod h1:ECF8anFVCt/TfTIWVPgPrNaYJXtAtpAOF62ugDbw41A= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nlopes/slack v0.6.0/go.mod h1:JzQ9m3PMAqcpeCam7UaHSuBuupz7CmpjehYMayT6YOk= @@ -724,7 +715,7 @@ github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DV github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/oleiade/reflections v1.0.0/go.mod h1:RbATFBbKYkVdqmSFtx13Bb/tVhR0lgOBXunWTZKeL4w= github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= -github.com/olekukonko/tablewriter v0.0.3/go.mod h1:YZeBtGzYYEsCHp2LST/u/0NDwGkRoBtmn1cIWCJiS6M= +github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -931,7 +922,6 @@ github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGr github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8= @@ -1069,11 +1059,9 @@ golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20191027093000-83d349e8ac1a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191028085509-fe3aa8a45271/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191109021931-daa7c04131f5/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191207000613-e7e4b65ae663/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a h1:GuSPYbZzB5/dcLNCwLQLsg3obCJtX9IJhpXkvY7kzk0= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -1219,6 +1207,8 @@ google.golang.org/genproto v0.0.0-20200420144010-e5e8543f8aeb h1:nAFaltAMbNVA0ri google.golang.org/genproto v0.0.0-20200420144010-e5e8543f8aeb/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece h1:1YM0uhfumvoDu9sx8+RyWwTI63zoCQvI23IYFRlvte0= +google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/grpc v1.26.0 h1:2dTRdpdFEEhJYQD8EMLB61nnrzSCTbG38PhqdhvOltg= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= @@ -1230,6 +1220,7 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= gopkg.in/DataDog/dd-trace-go.v1 v1.19.0/go.mod h1:DVp8HmDh8PuTu2Z0fVVlBsyWaC++fzwVCaGWylTe3tg= @@ -1268,7 +1259,6 @@ gopkg.in/ldap.v2 v2.5.1/go.mod h1:oI0cpe/D7HRtBQl8aTg+ZmzFUAvu4lsv3eLXMLGFxWk= gopkg.in/ldap.v3 v3.1.0/go.mod h1:dQjCc0R0kfyFjIlWNMH1DORwUASZyDxo2Ry1B51dXaQ= gopkg.in/ns1/ns1-go.v2 v2.0.0-20190730140822-b51389932cbc/go.mod h1:VV+3haRsgDiVLxyifmMBrBIuCWFBPYKbRssXB9z67Hw= gopkg.in/olivere/elastic.v5 v5.0.82/go.mod h1:uhHoB4o3bvX5sorxBU29rPcmBQdV2Qfg0FBrx5D6pV0= -gopkg.in/olivere/elastic.v5 v5.0.83/go.mod h1:LXF6q9XNBxpMqrcgax95C6xyARXWbbCXUrtTxrNrxJI= gopkg.in/redis.v3 v3.6.4/go.mod h1:6XeGv/CrsUFDU9aVbUdNykN7k1zVmoeg83KC9RbQfiU= gopkg.in/resty.v1 v1.9.1/go.mod h1:vo52Hzryw9PnPHcJfPsBiFW62XhNx5OczbV9y+IMpgc= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= diff --git a/pkg/command/add_account.go b/pkg/command/add_account.go new file mode 100644 index 0000000000..1e38ada77d --- /dev/null +++ b/pkg/command/add_account.go @@ -0,0 +1,56 @@ +package command + +import ( + "fmt" + "github.com/micro/cli/v2" + "github.com/micro/go-micro/v2/client/grpc" + "github.com/owncloud/ocis-accounts/pkg/config" + "github.com/owncloud/ocis-accounts/pkg/flagset" + accounts "github.com/owncloud/ocis-accounts/pkg/proto/v0" +) + +// AddAccount command creates a new account +func AddAccount(cfg *config.Config) *cli.Command { + a := &accounts.Account{ + PasswordProfile: &accounts.PasswordProfile{}, + } + return &cli.Command{ + Name: "add", + Usage: "Create a new account", + Aliases: []string{"create", "a"}, + Flags: flagset.AddAccountWithConfig(cfg, a), + Before: func(c *cli.Context) error { + // Write value of username to the flags beneath, as preferred name + // and on-premises-sam-account-name is probably confusing for users. + if username := c.String("username"); username != "" { + if !c.IsSet("on-premises-sam-account-name") { + if err := c.Set("on-premises-sam-account-name", username); err != nil { + return err + } + } + + if !c.IsSet("preferred-name") { + if err := c.Set("preferred-name", username); err != nil { + return err + } + } + } + + return nil + + }, + Action: func(c *cli.Context) error { + accSvcID := cfg.GRPC.Namespace + "." + cfg.Server.Name + accSvc := accounts.NewAccountsService(accSvcID, grpc.NewClient()) + _, err := accSvc.CreateAccount(c.Context, &accounts.CreateAccountRequest{ + Account: a, + }) + + if err != nil { + fmt.Println(fmt.Errorf("could not create account %w", err)) + return err + } + + return nil + }} +} diff --git a/pkg/command/inspect_account.go b/pkg/command/inspect_account.go new file mode 100644 index 0000000000..4fd2c19242 --- /dev/null +++ b/pkg/command/inspect_account.go @@ -0,0 +1,76 @@ +package command + +import ( + "fmt" + "github.com/micro/cli/v2" + "github.com/micro/go-micro/v2/client/grpc" + tw "github.com/olekukonko/tablewriter" + "github.com/owncloud/ocis-accounts/pkg/config" + "github.com/owncloud/ocis-accounts/pkg/flagset" + accounts "github.com/owncloud/ocis-accounts/pkg/proto/v0" + "os" + "strconv" +) + +// InspectAccount command shows detailed information about a specific account. +func InspectAccount(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "inspect", + Usage: "Show detailed data on an existing account", + ArgsUsage: "id", + Flags: flagset.InspectAccountWithConfig(cfg), + Action: func(c *cli.Context) error { + accServiceID := cfg.GRPC.Namespace + "." + cfg.Server.Name + if c.NArg() != 1 { + fmt.Println("Please provide a user-id") + os.Exit(1) + } + + uid := c.Args().First() + accSvc := accounts.NewAccountsService(accServiceID, grpc.NewClient()) + acc, err := accSvc.GetAccount(c.Context, &accounts.GetAccountRequest{ + Id: uid, + }) + + if err != nil { + fmt.Println(fmt.Errorf("could not view account %w", err)) + return err + } + + buildAccountInspectTable(acc).Render() + return nil + }} +} + +func buildAccountInspectTable(acc *accounts.Account) *tw.Table { + table := tw.NewWriter(os.Stdout) + table.SetAutoMergeCells(true) + table.AppendBulk([][]string{ + {"ID", acc.Id}, + {"Mail", acc.Mail}, + {"DisplayName", acc.DisplayName}, + {"PreferredName", acc.PreferredName}, + {"AccountEnabled", strconv.FormatBool(acc.AccountEnabled)}, + {"CreationType", acc.CreationType}, + {"CreatedDateTime", acc.CreatedDateTime.String()}, + {"Description", acc.Description}, + {"ExternalUserState", acc.ExternalUserState}, + {"UidNumber", fmt.Sprintf("%+d", acc.UidNumber)}, + {"GidNumber", fmt.Sprintf("%+d", acc.GidNumber)}, + {"IsResourceAccount", strconv.FormatBool(acc.IsResourceAccount)}, + {"OnPremisesDistinguishedName", acc.OnPremisesDistinguishedName}, + {"OnPremisesDomainName", acc.OnPremisesDomainName}, + {"OnPremisesImmutableId", acc.OnPremisesImmutableId}, + {"OnPremisesSamAccountName", acc.OnPremisesSamAccountName}, + {"OnPremisesSecurityIdentifier", acc.OnPremisesSecurityIdentifier}, + {"OnPremisesUserPrincipalName", acc.OnPremisesUserPrincipalName}, + {"RefreshTokenValidFromDateTime", acc.RefreshTokensValidFromDateTime.String()}, + }) + + // Merged cell with group memberships + for k := range acc.MemberOf { + table.Append([]string{"MemberOf", acc.MemberOf[k].DisplayName}) + } + + return table +} diff --git a/pkg/command/list_accounts.go b/pkg/command/list_accounts.go new file mode 100644 index 0000000000..c387961505 --- /dev/null +++ b/pkg/command/list_accounts.go @@ -0,0 +1,50 @@ +package command + +import ( + "fmt" + "github.com/micro/cli/v2" + "github.com/micro/go-micro/v2/client/grpc" + tw "github.com/olekukonko/tablewriter" + "github.com/owncloud/ocis-accounts/pkg/config" + "github.com/owncloud/ocis-accounts/pkg/flagset" + accounts "github.com/owncloud/ocis-accounts/pkg/proto/v0" + "os" + "strconv" +) + +// ListAccounts command lists all accounts +func ListAccounts(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "list", + Usage: "List existing accounts", + Aliases: []string{"ls"}, + Flags: flagset.ListAccountsWithConfig(cfg), + Action: func(c *cli.Context) error { + accSvcID := cfg.GRPC.Namespace + "." + cfg.Server.Name + accSvc := accounts.NewAccountsService(accSvcID, grpc.NewClient()) + resp, err := accSvc.ListAccounts(c.Context, &accounts.ListAccountsRequest{}) + + if err != nil { + fmt.Println(fmt.Errorf("could not list accounts %w", err)) + return err + } + + buildAccountsListTable(resp.Accounts).Render() + return nil + }} +} + +// buildAccountsListTable creates an ascii table for printing on the cli +func buildAccountsListTable(accs []*accounts.Account) *tw.Table { + table := tw.NewWriter(os.Stdout) + table.SetHeader([]string{"Id", "DisplayName", "Mail", "AccountEnabled"}) + table.SetAutoFormatHeaders(false) + for _, acc := range accs { + table.Append([]string{ + acc.Id, + acc.DisplayName, + acc.Mail, + strconv.FormatBool(acc.AccountEnabled)}) + } + return table +} diff --git a/pkg/command/remove_account.go b/pkg/command/remove_account.go new file mode 100644 index 0000000000..b141e2bef6 --- /dev/null +++ b/pkg/command/remove_account.go @@ -0,0 +1,39 @@ +package command + +import ( + "fmt" + "github.com/micro/cli/v2" + "github.com/micro/go-micro/v2/client/grpc" + "github.com/owncloud/ocis-accounts/pkg/config" + "github.com/owncloud/ocis-accounts/pkg/flagset" + accounts "github.com/owncloud/ocis-accounts/pkg/proto/v0" + "os" +) + +// RemoveAccount command deletes an existing account. +func RemoveAccount(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "remove", + Usage: "Removes an existing account", + ArgsUsage: "id", + Aliases: []string{"rm"}, + Flags: flagset.RemoveAccountWithConfig(cfg), + Action: func(c *cli.Context) error { + accServiceID := cfg.GRPC.Namespace + "." + cfg.Server.Name + if c.NArg() != 1 { + fmt.Println("Please provide a user-id") + os.Exit(1) + } + + uid := c.Args().First() + accSvc := accounts.NewAccountsService(accServiceID, grpc.NewClient()) + _, err := accSvc.DeleteAccount(c.Context, &accounts.DeleteAccountRequest{Id: uid}) + + if err != nil { + fmt.Println(fmt.Errorf("could not delete account %w", err)) + return err + } + + return nil + }} +} diff --git a/pkg/command/root.go b/pkg/command/root.go index 3f250f0e5c..de9802ab0a 100644 --- a/pkg/command/root.go +++ b/pkg/command/root.go @@ -42,6 +42,11 @@ func Execute() error { Commands: []*cli.Command{ Server(cfg), + AddAccount(cfg), + UpdateAccount(cfg), + ListAccounts(cfg), + InspectAccount(cfg), + RemoveAccount(cfg), }, } diff --git a/pkg/command/update_account.go b/pkg/command/update_account.go new file mode 100644 index 0000000000..ead11f2cac --- /dev/null +++ b/pkg/command/update_account.go @@ -0,0 +1,87 @@ +package command + +import ( + "errors" + "fmt" + "github.com/micro/cli/v2" + "github.com/micro/go-micro/v2/client/grpc" + "github.com/owncloud/ocis-accounts/pkg/config" + "github.com/owncloud/ocis-accounts/pkg/flagset" + accounts "github.com/owncloud/ocis-accounts/pkg/proto/v0" + "google.golang.org/genproto/protobuf/field_mask" +) + +// UpdateAccount command for modifying accounts including password policies +func UpdateAccount(cfg *config.Config) *cli.Command { + a := &accounts.Account{ + PasswordProfile: &accounts.PasswordProfile{}, + } + return &cli.Command{ + Name: "update", + Usage: "Make changes to an existing account", + ArgsUsage: "id", + Flags: flagset.UpdateAccountWithConfig(cfg, a), + Before: func(c *cli.Context) error { + if len(c.StringSlice("password_policies")) > 0 { + // StringSliceFlag doesn't support Destination + a.PasswordProfile.PasswordPolicies = c.StringSlice("password_policies") + } + + if c.NArg() != 1 { + return errors.New("missing account-id") + } + + if c.NumFlags() == 0 { + return errors.New("missing attribute-flags for update") + } + + return nil + }, + Action: func(c *cli.Context) error { + a.Id = c.Args().First() + accSvcID := cfg.GRPC.Namespace + "." + cfg.Server.Name + accSvc := accounts.NewAccountsService(accSvcID, grpc.NewClient()) + _, err := accSvc.UpdateAccount(c.Context, &accounts.UpdateAccountRequest{ + Account: a, + UpdateMask: buildAccUpdateMask(c.FlagNames()), + }) + + if err != nil { + fmt.Println(fmt.Errorf("could not update account %w", err)) + return err + } + + return nil + }} +} + +// buildAccUpdateMask by mapping passed update flags to account fieldNames. +// +// The UpdateMask is passed with the update-request to the server so that +// only the modified values are transferred. +func buildAccUpdateMask(setFlags []string) *field_mask.FieldMask { + var flagToPath = map[string]string{ + "enabled": "AccountEnabled", + "displayname": "DisplayName", + "preferred-name": "PreferredName", + "uidnumber": "UidNumber", + "gidnumber": "GidNumber", + "mail": "Mail", + "description": "Description", + "password": "PasswordProfile.Password", + "password-policies": "PasswordProfile.PasswordPolicies", + "force-password-change": "PasswordProfile.ForceChangePasswordNextSignIn", + "force-password-change-mfa": "PasswordProfile.ForceChangePasswordNextSignInWithMfa", + "on-premises-sam-account-name": "OnPremisesSamAccountName", + } + + updatedPaths := make([]string, 0) + + for _, v := range setFlags { + if _, ok := flagToPath[v]; ok { + updatedPaths = append(updatedPaths, flagToPath[v]) + } + } + + return &field_mask.FieldMask{Paths: updatedPaths} +} diff --git a/pkg/flagset/flagset.go b/pkg/flagset/flagset.go index 140a801e3a..aff5f17b07 100644 --- a/pkg/flagset/flagset.go +++ b/pkg/flagset/flagset.go @@ -3,6 +3,7 @@ package flagset import ( "github.com/micro/cli/v2" "github.com/owncloud/ocis-accounts/pkg/config" + accounts "github.com/owncloud/ocis-accounts/pkg/proto/v0" ) // RootWithConfig applies cfg to the root flagset @@ -88,8 +89,240 @@ func ServerWithConfig(cfg *config.Config) []cli.Flag { Name: "asset-path", Value: "", Usage: "Path to custom assets", - EnvVars: []string{"HELLO_ASSET_PATH"}, + EnvVars: []string{"ACCOUNTS_ASSET_PATH"}, Destination: &cfg.Asset.Path, }, } } + +// UpdateAccountWithConfig applies update command flags to cfg +func UpdateAccountWithConfig(cfg *config.Config, a *accounts.Account) []cli.Flag { + if a.PasswordProfile == nil { + a.PasswordProfile = &accounts.PasswordProfile{} + } + + return []cli.Flag{ + &cli.StringFlag{ + Name: "grpc-namespace", + Value: "com.owncloud.api", + Usage: "Set the base namespace for the grpc namespace", + EnvVars: []string{"ACCOUNTS_GRPC_NAMESPACE"}, + Destination: &cfg.GRPC.Namespace, + }, + &cli.StringFlag{ + Name: "name", + Value: "accounts", + Usage: "service name", + EnvVars: []string{"ACCOUNTS_NAME"}, + Destination: &cfg.Server.Name, + }, + &cli.BoolFlag{ + Name: "enabled", + Usage: "Enable the account", + Destination: &a.AccountEnabled, + }, + &cli.StringFlag{ + Name: "displayname", + Usage: "Set the displayname for the account", + Destination: &a.DisplayName, + }, + &cli.StringFlag{ + Name: "preferred-name", + Usage: "Set the preferred-name for the account", + Destination: &a.PreferredName, + }, + &cli.StringFlag{ + Name: "on-premises-sam-account-name", + Usage: "Set the on-premises-sam-account-name", + Destination: &a.OnPremisesSamAccountName, + }, + &cli.Int64Flag{ + Name: "uidnumber", + Usage: "Set the uidnumber for the account", + Destination: &a.UidNumber, + }, + &cli.Int64Flag{ + Name: "gidnumber", + Usage: "Set the gidnumber for the account", + Destination: &a.GidNumber, + }, + &cli.StringFlag{ + Name: "mail", + Usage: "Set the mail for the account", + Destination: &a.Mail, + }, + &cli.StringFlag{ + Name: "description", + Usage: "Set the description for the account", + Destination: &a.Description, + }, + &cli.StringFlag{ + Name: "password", + Usage: "Set the password for the account", + Destination: &a.PasswordProfile.Password, + // TODO read password from ENV? + }, + &cli.StringSliceFlag{ + Name: "password-policies", + Usage: "Possible policies: DisableStrongPassword, DisablePasswordExpiration", + }, + &cli.BoolFlag{ + Name: "force-password-change", + Usage: "Force password change on next sign-in", + Destination: &a.PasswordProfile.ForceChangePasswordNextSignIn, + }, + &cli.BoolFlag{ + Name: "force-password-change-mfa", + Usage: "Force password change on next sign-in with mfa", + Destination: &a.PasswordProfile.ForceChangePasswordNextSignInWithMfa, + }, + } +} + +// AddAccountWithConfig applies create command flags to cfg +func AddAccountWithConfig(cfg *config.Config, a *accounts.Account) []cli.Flag { + if a.PasswordProfile == nil { + a.PasswordProfile = &accounts.PasswordProfile{} + } + + return []cli.Flag{ + &cli.StringFlag{ + Name: "grpc-namespace", + Value: "com.owncloud.api", + Usage: "Set the base namespace for the grpc namespace", + EnvVars: []string{"ACCOUNTS_GRPC_NAMESPACE"}, + Destination: &cfg.GRPC.Namespace, + }, + &cli.StringFlag{ + Name: "name", + Value: "accounts", + Usage: "service name", + EnvVars: []string{"ACCOUNTS_NAME"}, + Destination: &cfg.Server.Name, + }, + &cli.BoolFlag{ + Name: "enabled", + Usage: "Enable the account", + Destination: &a.AccountEnabled, + }, + &cli.StringFlag{ + Name: "displayname", + Usage: "Set the displayname for the account", + Destination: &a.DisplayName, + }, + &cli.StringFlag{ + Name: "username", + Usage: "Username will be written to preferred-name and on_premises_sam_account_name", + }, + &cli.StringFlag{ + Name: "preferred-name", + Usage: "Set the preferred-name for the account", + Destination: &a.PreferredName, + }, + &cli.StringFlag{ + Name: "on-premises-sam-account-name", + Usage: "Set the on-premises-sam-account-name", + Destination: &a.OnPremisesSamAccountName, + }, + &cli.Int64Flag{ + Name: "uidnumber", + Usage: "Set the uidnumber for the account", + Destination: &a.UidNumber, + }, + &cli.Int64Flag{ + Name: "gidnumber", + Usage: "Set the gidnumber for the account", + Destination: &a.GidNumber, + }, + &cli.StringFlag{ + Name: "mail", + Usage: "Set the mail for the account", + Destination: &a.Mail, + }, + &cli.StringFlag{ + Name: "description", + Usage: "Set the description for the account", + Destination: &a.Description, + }, + &cli.StringFlag{ + Name: "password", + Usage: "Set the password for the account", + Destination: &a.PasswordProfile.Password, + // TODO read password from ENV? + }, + &cli.StringSliceFlag{ + Name: "password-policies", + Usage: "Possible policies: DisableStrongPassword, DisablePasswordExpiration", + }, + &cli.BoolFlag{ + Name: "force-password-change", + Usage: "Force password change on next sign-in", + Destination: &a.PasswordProfile.ForceChangePasswordNextSignIn, + }, + &cli.BoolFlag{ + Name: "force-password-change-mfa", + Usage: "Force password change on next sign-in with mfa", + Destination: &a.PasswordProfile.ForceChangePasswordNextSignInWithMfa, + }, + } +} + +// ListAccountsWithConfig applies list command flags to cfg +func ListAccountsWithConfig(cfg *config.Config) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "grpc-namespace", + Value: "com.owncloud.api", + Usage: "Set the base namespace for the grpc namespace", + EnvVars: []string{"ACCOUNTS_GRPC_NAMESPACE"}, + Destination: &cfg.GRPC.Namespace, + }, + &cli.StringFlag{ + Name: "name", + Value: "accounts", + Usage: "service name", + EnvVars: []string{"ACCOUNTS_NAME"}, + Destination: &cfg.Server.Name, + }, + } +} + +// RemoveAccountWithConfig applies remove command flags to cfg +func RemoveAccountWithConfig(cfg *config.Config) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "grpc-namespace", + Value: "com.owncloud.api", + Usage: "Set the base namespace for the grpc namespace", + EnvVars: []string{"ACCOUNTS_GRPC_NAMESPACE"}, + Destination: &cfg.GRPC.Namespace, + }, + &cli.StringFlag{ + Name: "name", + Value: "accounts", + Usage: "service name", + EnvVars: []string{"ACCOUNTS_NAME"}, + Destination: &cfg.Server.Name, + }, + } +} + +// InspectAccountWithConfig applies inspect command flags to cfg +func InspectAccountWithConfig(cfg *config.Config) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "grpc-namespace", + Value: "com.owncloud.api", + Usage: "Set the base namespace for the grpc namespace", + EnvVars: []string{"ACCOUNTS_GRPC_NAMESPACE"}, + Destination: &cfg.GRPC.Namespace, + }, + &cli.StringFlag{ + Name: "name", + Value: "accounts", + Usage: "service name", + EnvVars: []string{"ACCOUNTS_NAME"}, + Destination: &cfg.Server.Name, + }, + } +} diff --git a/pkg/proto/v0/accounts.pb.micro_test.go b/pkg/proto/v0/accounts.pb.micro_test.go index fa9482095b..7304f5e3e5 100644 --- a/pkg/proto/v0/accounts.pb.micro_test.go +++ b/pkg/proto/v0/accounts.pb.micro_test.go @@ -2,7 +2,10 @@ package proto_test import ( context "context" + "errors" "fmt" + "google.golang.org/genproto/protobuf/field_mask" + "google.golang.org/protobuf/types/known/timestamppb" "log" "os" "path/filepath" @@ -17,6 +20,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/golang/protobuf/ptypes/empty" + merrors "github.com/micro/go-micro/v2/errors" ) var service = grpc.Service{} @@ -949,3 +953,55 @@ func TestListMembersEmptyGroup(t *testing.T) { cleanUp(t) } + +func TestAccountUpdateMask(t *testing.T) { + createAccount(t, "user1") + user1 := getAccount("user1") + client := service.Client() + req := &proto.UpdateAccountRequest{ + // We only want to update the display-name, rest should be ignored + UpdateMask: &field_mask.FieldMask{Paths: []string{"DisplayName"}}, + Account: &proto.Account{ + Id: user1.Id, + DisplayName: "ShouldBeUpdated", + PreferredName: "ShouldStaySame", + }} + + cl := proto.NewAccountsService("com.owncloud.api.accounts", client) + res, err := cl.UpdateAccount(context.Background(), req) + checkError(t, err) + + assert.Equal(t, "ShouldBeUpdated", res.DisplayName) + assert.Equal(t, user1.PreferredName, res.PreferredName) + + cleanUp(t) +} + +func TestAccountUpdateReadOnlyField(t *testing.T) { + createAccount(t, "user1") + user1 := getAccount("user1") + client := service.Client() + req := &proto.UpdateAccountRequest{ + // We only want to update the display-name, rest should be ignored + UpdateMask: &field_mask.FieldMask{Paths: []string{"CreatedDateTime"}}, + Account: &proto.Account{ + Id: user1.Id, + CreatedDateTime: timestamppb.Now(), + }} + + cl := proto.NewAccountsService("com.owncloud.api.accounts", client) + res, err := cl.UpdateAccount(context.Background(), req) + assert.Nil(t, res) + assert.Error(t, err) + + var e *merrors.Error + + if errors.As(err, &e) { + assert.EqualValues(t, 400, e.Code) + assert.Equal(t, "Bad Request", e.Status) + } else { + t.Fatal("Unexpected error type") + } + + cleanUp(t) +} diff --git a/pkg/service/v0/accounts.go b/pkg/service/v0/accounts.go index 5e36b2ec5d..6e368ce9ec 100644 --- a/pkg/service/v0/accounts.go +++ b/pkg/service/v0/accounts.go @@ -4,6 +4,9 @@ import ( "context" "encoding/json" "fmt" + fieldmask_utils "github.com/mennanov/fieldmask-utils" + "github.com/rs/zerolog" + "google.golang.org/genproto/protobuf/field_mask" "io/ioutil" "os" "path/filepath" @@ -28,7 +31,6 @@ import ( ) func (s Service) indexAccounts(path string) (err error) { - var f *os.File if f, err = os.Open(path); err != nil { s.log.Error().Err(err).Str("dir", path).Msg("could not open accounts folder") @@ -81,14 +83,6 @@ func (s Service) loadAccount(id string, a *proto.Account) (err error) { return } -// loggableAccount redacts the password from the account -func loggableAccount(a *proto.Account) *proto.Account { - if a != nil && a.PasswordProfile != nil { - a.PasswordProfile.Password = "***REMOVED***" - } - return a -} - func (s Service) writeAccount(a *proto.Account) (err error) { // leave only the group id @@ -207,7 +201,6 @@ func (s Service) ListAccounts(ctx context.Context, in *proto.ListAccountsRequest out.Accounts = make([]*proto.Account, 0) for _, hit := range searchResult.Hits { - a := &proto.Account{} if err = s.loadAccount(hit.ID, a); err != nil { s.log.Error().Err(err).Str("account", hit.ID).Msg("could not load account, skipping") @@ -217,11 +210,12 @@ func (s Service) ListAccounts(ctx context.Context, in *proto.ListAccountsRequest if a.PasswordProfile != nil { currentHash = a.PasswordProfile.Password } - s.log.Debug().Interface("account", loggableAccount(a)).Msg("found account") + + s.debugLogAccount(a).Msg("found account") if password != "" { if a.PasswordProfile == nil { - s.log.Debug().Interface("account", loggableAccount(a)).Msg("no password profile") + s.debugLogAccount(a).Msg("no password profile") return merrors.Unauthorized(s.id, "invalid password") } if !s.passwordIsValid(currentHash, password) { @@ -233,7 +227,6 @@ func (s Service) ListAccounts(ctx context.Context, in *proto.ListAccountsRequest s.expandMemberOf(a) // remove password before returning - if a.PasswordProfile != nil { a.PasswordProfile.Password = "" } @@ -255,7 +248,8 @@ func (s Service) GetAccount(c context.Context, in *proto.GetAccountRequest, out s.log.Error().Err(err).Str("id", id).Msg("could not load account") return } - s.log.Debug().Interface("account", loggableAccount(out)).Msg("found account") + + s.debugLogAccount(out).Msg("found account") // TODO add groups if requested // if in.FieldMask ... @@ -272,40 +266,48 @@ func (s Service) GetAccount(c context.Context, in *proto.GetAccountRequest, out // CreateAccount implements the AccountsServiceHandler interface func (s Service) CreateAccount(c context.Context, in *proto.CreateAccountRequest, out *proto.Account) (err error) { var id string - if in.Account == nil { + var acc = in.Account + if acc == nil { return merrors.BadRequest(s.id, "account missing") } - if in.Account.Id == "" { - in.Account.Id = uuid.Must(uuid.NewV4()).String() + if acc.Id == "" { + acc.Id = uuid.Must(uuid.NewV4()).String() } - if !s.isValidUsername(in.Account.PreferredName) { - return merrors.BadRequest(s.id, "preferred_name '%s' must be at least the local part of an email", in.Account.PreferredName) + if !s.isValidUsername(acc.PreferredName) { + return merrors.BadRequest(s.id, "preferred_name '%s' must be at least the local part of an email", acc.PreferredName) } - if !s.isValidEmail(in.Account.Mail) { - return merrors.BadRequest(s.id, "mail '%s' must be a valid email", in.Account.Mail) + if !s.isValidEmail(acc.Mail) { + return merrors.BadRequest(s.id, "mail '%s' must be a valid email", acc.Mail) } - if id, err = cleanupID(in.Account.Id); err != nil { + if id, err = cleanupID(acc.Id); err != nil { return merrors.InternalServerError(s.id, "could not clean up account id: %v", err.Error()) } - if in.Account.PasswordProfile != nil && in.Account.PasswordProfile.Password != "" { - // encrypt password - c := crypt.New(crypt.SHA512) - if in.Account.PasswordProfile.Password, err = c.Generate([]byte(in.Account.PasswordProfile.Password), nil); err != nil { - s.log.Error().Err(err).Str("id", id).Interface("account", loggableAccount(in.Account)).Msg("could not hash password") - return merrors.InternalServerError(s.id, "could not hash password: %v", err.Error()) + if acc.PasswordProfile != nil { + if acc.PasswordProfile.Password != "" { + // encrypt password + c := crypt.New(crypt.SHA512) + if acc.PasswordProfile.Password, err = c.Generate([]byte(acc.PasswordProfile.Password), nil); err != nil { + s.log.Error().Err(err).Str("id", id).Msg("could not hash password") + return merrors.InternalServerError(s.id, "could not hash password: %v", err.Error()) + } + } + + if err := passwordPoliciesValid(acc.PasswordProfile.PasswordPolicies); err != nil { + return merrors.BadRequest(s.id, "%s", err) } } // extract group id // TODO groups should be ignored during create, use groups.AddMember? return error? - if err = s.writeAccount(in.Account); err != nil { - s.log.Error().Err(err).Interface("account", loggableAccount(in.Account)).Msg("could not persist new account") + if err = s.writeAccount(acc); err != nil { + s.log.Error().Err(err).Str("id", id).Msg("could not persist new account") + s.debugLogAccount(acc).Msg("could not persist new account") return } - if err = s.indexAccount(in.Account.Id); err != nil { + if err = s.indexAccount(acc.Id); err != nil { return merrors.InternalServerError(s.id, "could not index new account: %v", err.Error()) } @@ -323,23 +325,19 @@ func (s Service) UpdateAccount(c context.Context, in *proto.UpdateAccountRequest if in.Account.Id == "" { return merrors.BadRequest(s.id, "account id missing") } - if !s.isValidUsername(in.Account.PreferredName) { - return merrors.BadRequest(s.id, "preferred_name '%s' must be at least the local part of an email", in.Account.PreferredName) - } - if !s.isValidEmail(in.Account.Mail) { - return merrors.BadRequest(s.id, "mail '%s' must be a valid email", in.Account.Mail) - } if id, err = cleanupID(in.Account.Id); err != nil { return merrors.InternalServerError(s.id, "could not clean up account id: %v", err.Error()) } + path := filepath.Join(s.Config.Server.AccountsDataPath, "accounts", id) if err = s.loadAccount(id, out); err != nil { s.log.Error().Err(err).Str("id", id).Msg("could not load account") return } - s.log.Debug().Interface("account", loggableAccount(out)).Msg("found account") + + s.debugLogAccount(out).Msg("found account") t := time.Now() tsnow := ×tamppb.Timestamp{ @@ -347,17 +345,14 @@ func (s Service) UpdateAccount(c context.Context, in *proto.UpdateAccountRequest Nanos: int32(t.Nanosecond()), } - // id read-only - out.AccountEnabled = in.Account.AccountEnabled - out.IsResourceAccount = in.Account.IsResourceAccount - // creation-type read only - out.Identities = in.Account.Identities - out.DisplayName = in.Account.DisplayName - out.PreferredName = in.Account.PreferredName - out.UidNumber = in.Account.UidNumber - out.GidNumber = in.Account.GidNumber - out.Mail = in.Account.Mail // read only? - out.Description = in.Account.Description + validMask, err := validateUpdate(in.UpdateMask, updatableAccountPaths) + if err != nil { + return merrors.BadRequest(s.id, "%s", err) + } + + if err := fieldmask_utils.StructToStruct(validMask, in.Account, out); err != nil { + return merrors.InternalServerError(s.id, "%s", err) + } if in.Account.PasswordProfile != nil { if out.PasswordProfile == nil { @@ -367,39 +362,39 @@ func (s Service) UpdateAccount(c context.Context, in *proto.UpdateAccountRequest // encrypt password c := crypt.New(crypt.SHA512) if out.PasswordProfile.Password, err = c.Generate([]byte(in.Account.PasswordProfile.Password), nil); err != nil { - s.log.Error().Err(err).Str("id", id).Interface("account", loggableAccount(in.Account)).Msg("could not hash password") + in.Account.PasswordProfile.Password = "" + s.log.Error().Err(err).Str("id", id).Msg("could not hash password") return merrors.InternalServerError(s.id, "could not hash password: %v", err.Error()) } + + in.Account.PasswordProfile.Password = "" } + + if err := passwordPoliciesValid(in.Account.PasswordProfile.PasswordPolicies); err != nil { + return merrors.BadRequest(s.id, "%s", err) + } + // lastPasswordChangeDateTime calculated, see password - out.PasswordProfile.PasswordPolicies = in.Account.PasswordProfile.PasswordPolicies - out.PasswordProfile.ForceChangePasswordNextSignIn = in.Account.PasswordProfile.ForceChangePasswordNextSignIn - out.PasswordProfile.ForceChangePasswordNextSignInWithMfa = in.Account.PasswordProfile.ForceChangePasswordNextSignInWithMfa out.PasswordProfile.LastPasswordChangeDateTime = tsnow } - // memberOf read only - // createdDateTime read only - // deleteDateTime read only + // out.RefreshTokensValidFromDateTime TODO use to invalidate all existing sessions + // out.SignInSessionsValidFromDateTime TODO use to invalidate all existing sessions - out.OnPremisesSyncEnabled = in.Account.OnPremisesSyncEnabled - out.OnPremisesSamAccountName = in.Account.OnPremisesSamAccountName // ... TODO on prem for sync if out.ExternalUserState != in.Account.ExternalUserState { out.ExternalUserState = in.Account.ExternalUserState out.ExternalUserStateChangeDateTime = tsnow } - // out.RefreshTokensValidFromDateTime TODO use to invalidate all existing sessions - // out.SignInSessionsValidFromDateTime TODO use to invalidate all existing sessions if err = s.writeAccount(out); err != nil { - s.log.Error().Err(err).Interface("account", loggableAccount(out)).Msg("could not persist updated account") + s.log.Error().Err(err).Str("id", out.Id).Msg("could not persist updated account") return } if err = s.indexAccount(id); err != nil { - s.log.Error().Err(err).Str("id", id).Str("path", path).Interface("account", loggableAccount(out)).Msg("could not index new account") + s.log.Error().Err(err).Str("id", id).Str("path", path).Msg("could not index new account") return merrors.InternalServerError(s.id, "could not index updated account: %v", err.Error()) } @@ -411,6 +406,25 @@ func (s Service) UpdateAccount(c context.Context, in *proto.UpdateAccountRequest return } +// whitelist of all paths/fields which can be updated by clients +var updatableAccountPaths = map[string]struct{}{ + "AccountEnabled": {}, + "IsResourceAccount": {}, + "Identities": {}, + "DisplayName": {}, + "PreferredName": {}, + "UidNumber": {}, + "GidNumber": {}, + "Description": {}, + "Mail": {}, // read only?, + "PasswordProfile.Password": {}, + "PasswordProfile.PasswordPolicies": {}, + "PasswordProfile.ForceChangePasswordNextSignIn": {}, + "PasswordProfile.ForceChangePasswordNextSignInWithMfa": {}, + "OnPremisesSyncEnabled": {}, + "OnPremisesSamAccountName": {}, +} + // DeleteAccount implements the AccountsServiceHandler interface func (s Service) DeleteAccount(c context.Context, in *proto.DeleteAccountRequest, out *empty.Empty) (err error) { var id string @@ -471,3 +485,72 @@ func (s Service) isValidEmail(e string) bool { } return emailRegex.MatchString(e) } + +const ( + policyDisableStrongPassword = "DisableStrongPassword" + policyDisablePasswordExpiration = "DisablePasswordExpiration" +) + +func passwordPoliciesValid(policies []string) error { + for _, v := range policies { + if v != policyDisableStrongPassword && v != policyDisablePasswordExpiration { + return fmt.Errorf("invalid password-policy %s", v) + } + } + + return nil +} + +// validateUpdate takes a update field-mask and validates it against a whitelist of updatable paths. +// Returns a FieldFilter on success which can be passed to the fieldmask_utils..StructToStruct. An error is returned +// if the mask tries to update no whitelisted fields. +// +// Given an empty or nil mask we assume that the client wants to update all whitelisted fields. +// +func validateUpdate(mask *field_mask.FieldMask, updatablePaths map[string]struct{}) (fieldmask_utils.FieldFilterContainer, error) { + nop := func(s string) string { return s } + // Assume that the client wants to update all updatable path if + // no field-mask is given, so we create a mask with all paths + if mask == nil || len(mask.Paths) == 0 { + paths := make([]string, 0, len(updatablePaths)) + for fieldName := range updatablePaths { + paths = append(paths, fieldName) + } + + return fieldmask_utils.MaskFromPaths(paths, nop) + } + + // Check that only allowed fields are updated + for _, v := range mask.Paths { + if _, ok := updatablePaths[v]; !ok { + return nil, fmt.Errorf("can not update field %s, either unknown or readonly", v) + } + } + + return fieldmask_utils.MaskFromPaths(mask.Paths, nop) +} + +// debugLogAccount returns a debug-log event with detailed account-info, and filtered password data +func (s Service) debugLogAccount(a *proto.Account) *zerolog.Event { + return s.log.Debug().Fields(map[string]interface{}{ + "Id": a.Id, + "Mail": a.Mail, + "DisplayName": a.DisplayName, + "AccountEnabled": a.AccountEnabled, + "IsResourceAccount": a.IsResourceAccount, + "Identities": a.Identities, + "PreferredName": a.PreferredName, + "UidNumber": a.UidNumber, + "GidNumber": a.GidNumber, + "Description": a.Description, + "OnPremisesSyncEnabled": a.OnPremisesSyncEnabled, + "OnPremisesSamAccountName": a.OnPremisesSamAccountName, + "OnPremisesUserPrincipalName": a.OnPremisesUserPrincipalName, + "OnPremisesSecurityIdentifier": a.OnPremisesSecurityIdentifier, + "OnPremisesDistinguishedName": a.OnPremisesDistinguishedName, + "OnPremisesLastSyncDateTime": a.OnPremisesLastSyncDateTime, + "MemberOf": a.MemberOf, + "CreatedDateTime": a.CreatedDateTime, + "DeletedDateTime": a.DeletedDateTime, + }) +}