From de2f1b3ac1ef58f2f40ecd08e319e80ca72ad273 Mon Sep 17 00:00:00 2001 From: Sudhir Nimavat Date: Fri, 25 Apr 2025 07:24:00 +0530 Subject: [PATCH 1/7] Support name or id in gridOpts.colModel for excel export (#870) 9ci/domain9#2888 --- .../groovy/yakworks/rest/ExcelExportSpec.groovy | 2 +- .../groovy/yakworks/rest/OrgRestApiSpec.groovy | 2 -- .../rally-api/src/main/resources/restapi/rally/org.yml | 1 + .../yakworks/etl/excel/ExcelBuilderSupport.groovy | 10 ++++++---- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/ExcelExportSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/ExcelExportSpec.groovy index c2af01c17..c5a0f57c6 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/rest/ExcelExportSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/ExcelExportSpec.groovy @@ -33,6 +33,6 @@ class ExcelExportSpec extends Specification implements OkHttpRestTrait, WithTrx then: "column name should have been resolved from grid col model" headers //flex.text1 does not have a label, so it would have used getNaturalTitle on name - headers.containsAll(['Num', 'Name', 'Type', 'TotalDue', 'Flex Text1']) + headers.containsAll(['Num', 'Name', 'Type', 'TotalDue', 'Flex Text1', "Text 2"]) } } diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy index e35f99b3d..1c418de33 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy @@ -372,8 +372,6 @@ class OrgRestApiSpec extends Specification implements OkHttpRestTrait, WithTrx { } - - void "test post with tags"() { when: "Create a test tag" Tag tag1 = Tag.create(code: 'T1', entityName: 'Customer') diff --git a/examples/rally-api/src/main/resources/restapi/rally/org.yml b/examples/rally-api/src/main/resources/restapi/rally/org.yml index 48f442e2f..b81b2eb15 100644 --- a/examples/rally-api/src/main/resources/restapi/rally/org.yml +++ b/examples/rally-api/src/main/resources/restapi/rally/org.yml @@ -14,6 +14,7 @@ api: - {name: type.name, label: Type, width: 40} - {name: calc.totalDue, label: TotalDue, width: 40, formatter: currency} - {name: flex.text1, width: 40} #A column without label, should not fail, and would use name as header, both in grid and in xslx export + - {id: flex.text2, label: Text 2} #use id instead of name, excel builder should work with either of them # shrinkToFit: true contextMenu: true diff --git a/gorm-etl/src/main/groovy/yakworks/etl/excel/ExcelBuilderSupport.groovy b/gorm-etl/src/main/groovy/yakworks/etl/excel/ExcelBuilderSupport.groovy index b618f3262..dcc38f004 100644 --- a/gorm-etl/src/main/groovy/yakworks/etl/excel/ExcelBuilderSupport.groovy +++ b/gorm-etl/src/main/groovy/yakworks/etl/excel/ExcelBuilderSupport.groovy @@ -60,10 +60,12 @@ class ExcelBuilderSupport { if(colModel){ colModel.each { if(!it.hidden) { - //colModel would need to atleast specify field name, but if label is provided it would be used for xls header - //name is the name of domain field, label is what gets displayed as xls header - Validate.notEmpty(it.name, "name is required for columns in gridOptions.colMode") - colMap[(it.name as String)] = it.label as String + //colModel need to specify at least field name or id, name/id is the name of domain field, + //label is optional, if provided it would be used for xls header + + String columnName = it['name'] ?: it['id'] + Validate.notEmpty(columnName, "name or id is required for columns in gridOptions.colMode") + colMap[columnName] = it.label as String } } } From 0a46d2fd5edc0e93d596dd9cfd0e5be2275e0c6c Mon Sep 17 00:00:00 2001 From: Joshua Burnett Date: Tue, 29 Apr 2025 14:18:45 -0600 Subject: [PATCH 2/7] move AppTimeZone to yakworks commons (#888) * move AppTimeZone to yakworks commons * clean up --- .../rally/boot/RallyApiSpringConfig.groovy | 3 - .../openapi/gorm/ApiSchemaEntity.groovy | 1 - .../gorm/tools/mango/api/QueryService.java | 1 - gradle.properties | 2 +- .../yakworks/rally/RallyConfiguration.groovy | 2 +- .../rally/common/ZonedDateUtil.groovy | 55 ------- .../rally/config/AppRallyConfig.groovy | 4 +- .../rally/extensions/AppTimeZone.groovy | 61 -------- .../extensions/LocalDateTimeStaticExt.groovy | 48 ------- .../extensions/ZonedTimeStaticExt.groovy | 27 ---- ...rg.codehaus.groovy.runtime.ExtensionModule | 4 +- .../rally/common/ZonedDateUtilSpec.groovy | 134 ------------------ .../rally/extensions/AppTimeZoneSpec.groovy | 3 +- .../rally/job/MaintWindowUtilSpec.groovy | 2 +- .../gorm/SecurityGormConfiguration.groovy | 4 + .../gorm/api/UserSecurityConfig.groovy | 1 - .../spring/token/JwtProperties.groovy | 1 - 17 files changed, 12 insertions(+), 341 deletions(-) delete mode 100644 rally-domain/src/main/groovy/yakworks/rally/common/ZonedDateUtil.groovy delete mode 100644 rally-domain/src/main/groovy/yakworks/rally/extensions/AppTimeZone.groovy delete mode 100644 rally-domain/src/main/groovy/yakworks/rally/extensions/LocalDateTimeStaticExt.groovy delete mode 100644 rally-domain/src/main/groovy/yakworks/rally/extensions/ZonedTimeStaticExt.groovy delete mode 100644 rally-domain/src/test/groovy/yakworks/rally/common/ZonedDateUtilSpec.groovy diff --git a/examples/rally-api/src/main/groovy/yakworks/rally/boot/RallyApiSpringConfig.groovy b/examples/rally-api/src/main/groovy/yakworks/rally/boot/RallyApiSpringConfig.groovy index 9aaf3aebf..a47bc32b2 100644 --- a/examples/rally-api/src/main/groovy/yakworks/rally/boot/RallyApiSpringConfig.groovy +++ b/examples/rally-api/src/main/groovy/yakworks/rally/boot/RallyApiSpringConfig.groovy @@ -38,9 +38,6 @@ import static org.springframework.security.config.Customizer.withDefaults /** * An example of explicitly configuring Spring Security with the defaults. */ -// keep componentScan in Application.groovy for now so unit test work. see notes in the TestSpringApplication class in tests -// @ComponentScan(['yakity.security', 'yakworks.security']) -//@Lazy @EnableWebSecurity //(debug = true) @CompileStatic @Configuration diff --git a/gorm-openapi/src/main/groovy/yakworks/openapi/gorm/ApiSchemaEntity.groovy b/gorm-openapi/src/main/groovy/yakworks/openapi/gorm/ApiSchemaEntity.groovy index 22abb94cd..519a87cda 100644 --- a/gorm-openapi/src/main/groovy/yakworks/openapi/gorm/ApiSchemaEntity.groovy +++ b/gorm-openapi/src/main/groovy/yakworks/openapi/gorm/ApiSchemaEntity.groovy @@ -108,7 +108,6 @@ class ApiSchemaEntity { } @SuppressWarnings(['MethodSize']) - // @CompileDynamic private Map getEntityProperties(CruType type) { //println "----- ${perEntity.name} getDomainProperties ----" //String domainName = NameUtils.getPropertyNameRepresentation(perEntity.name) diff --git a/gorm-tools/src/main/groovy/gorm/tools/mango/api/QueryService.java b/gorm-tools/src/main/groovy/gorm/tools/mango/api/QueryService.java index 92815c9d1..56e3ab72d 100644 --- a/gorm-tools/src/main/groovy/gorm/tools/mango/api/QueryService.java +++ b/gorm-tools/src/main/groovy/gorm/tools/mango/api/QueryService.java @@ -19,7 +19,6 @@ * @author Joshua Burnett (@basejump) * @since 6.1 */ -// @CompileStatic public interface QueryService { Class getEntityClass(); diff --git a/gradle.properties b/gradle.properties index b5ec975aa..a8911682e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -43,7 +43,7 @@ spock.version=2.3-groovy-3.0 vSpringGrailsKit=5.4 vYakICU4J=5.5 -vGroovyCommons=3.16 +vGroovyCommons=3.17-SNAPSHOT # logging vLog4j=2.17.1 diff --git a/rally-domain/src/main/groovy/yakworks/rally/RallyConfiguration.groovy b/rally-domain/src/main/groovy/yakworks/rally/RallyConfiguration.groovy index ef5a26f6b..229cee357 100644 --- a/rally-domain/src/main/groovy/yakworks/rally/RallyConfiguration.groovy +++ b/rally-domain/src/main/groovy/yakworks/rally/RallyConfiguration.groovy @@ -19,7 +19,7 @@ import yakworks.security.gorm.api.UserSecurityConfig import yakworks.security.spring.DefaultSecurityConfiguration @Configuration @Lazy -@Import([DefaultSecurityConfiguration, SecurityGormConfiguration, AuditStampConfiguration, MailSpringConfig, UserSecurityConfig]) +@Import([DefaultSecurityConfiguration, SecurityGormConfiguration, AuditStampConfiguration, MailSpringConfig]) @ConfigurationPropertiesScan([ "yakworks.rally" ]) @ComponentScan(['yakworks.security.gorm', 'yakworks.rally']) @CompileStatic diff --git a/rally-domain/src/main/groovy/yakworks/rally/common/ZonedDateUtil.groovy b/rally-domain/src/main/groovy/yakworks/rally/common/ZonedDateUtil.groovy deleted file mode 100644 index 53580294f..000000000 --- a/rally-domain/src/main/groovy/yakworks/rally/common/ZonedDateUtil.groovy +++ /dev/null @@ -1,55 +0,0 @@ -/* -* Copyright 2024 Yak.Works - Licensed under the Apache License, Version 2.0 (the "License") -* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 -*/ -package yakworks.rally.common - -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.temporal.ChronoUnit -import java.time.temporal.Temporal - -import groovy.transform.CompileStatic - -@CompileStatic -class ZonedDateUtil { - - static LocalDateTime toLocalDateTimeZone(ZonedDateTime zonedDateTime, ZoneId toZoneId){ - return zonedDateTime - .withZoneSameInstant(toZoneId) - .toLocalDateTime() - } - - static ZonedDateTime toZonedDateTime(ZonedDateTime zonedDateTime, ZoneId toZoneId){ - return zonedDateTime.withZoneSameInstant(toZoneId) - } - - static LocalDateTime toLocalDateTimeZone(LocalDateTime localDateTime, ZoneId fromZoneId, ZoneId toZoneId){ - return localDateTime.atZone(fromZoneId) - .withZoneSameInstant(toZoneId) - .toLocalDateTime() - } - - //returns hours offset from UTC - static int hoursOffset(ZoneId zoneId){ - var offset = zoneId.getOffset() - BigDecimal hrs = offset.totalSeconds / 60 / 60 - return hrs.toInteger() - } - - //for testing, returns hours between 2 times. - static long hoursBetween(LocalDateTime ldtInclusive, LocalDateTime ldtExclusive) { - return ChronoUnit.HOURS.between(ldtInclusive, ldtExclusive) - } - - /** used for testing to see if 2 times are within 60 seconds of each other */ - static boolean isSameMinute(Temporal t1, Temporal t2){ - ChronoUnit.SECONDS.between(t1, t2).abs() < 60 - } - - /** used for testing to see if 2 times are within 60 seconds of each other */ - static boolean isSameSecond(Temporal t1, Temporal t2){ - ChronoUnit.SECONDS.between(t1, t2).abs() < 1 - } -} diff --git a/rally-domain/src/main/groovy/yakworks/rally/config/AppRallyConfig.groovy b/rally-domain/src/main/groovy/yakworks/rally/config/AppRallyConfig.groovy index 42a0d868f..076468c93 100644 --- a/rally-domain/src/main/groovy/yakworks/rally/config/AppRallyConfig.groovy +++ b/rally-domain/src/main/groovy/yakworks/rally/config/AppRallyConfig.groovy @@ -10,11 +10,9 @@ import groovy.transform.CompileStatic import org.springframework.boot.context.properties.ConfigurationProperties -import yakworks.rally.extensions.AppTimeZone +import yakworks.commons.extensions.AppTimeZone -// @Configuration(proxyBeanMethods = false) @ConfigurationProperties(prefix="app") -// @ConfigurationPropertiesScan @CompileStatic class AppRallyConfig { String hello = "world" diff --git a/rally-domain/src/main/groovy/yakworks/rally/extensions/AppTimeZone.groovy b/rally-domain/src/main/groovy/yakworks/rally/extensions/AppTimeZone.groovy deleted file mode 100644 index 179cf3773..000000000 --- a/rally-domain/src/main/groovy/yakworks/rally/extensions/AppTimeZone.groovy +++ /dev/null @@ -1,61 +0,0 @@ -/* -* Copyright 2023 Yak.Works - Licensed under the Apache License, Version 2.0 (the "License") -* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 -*/ -package yakworks.rally.extensions - -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneId - -import groovy.transform.CompileStatic - -/** - * Holder for the App default time-zone. - * The system zone will normally be set to UTC. - * The App can have a different default time zone for where most of the users are - * or the timezone accounting is based on. A GL posting date for example is usually anchored to a timezone for balancing purposes. - * - * So for example when I want an invoice or GL date for today and its 9:00pm Eastern time (new york) - * then its tomorrow at 1am in UTC so using the vanilla java LocalDate.now() will give a date for tomorrow when the accounting books - * are still open and running for today. - */ -@SuppressWarnings(['PropertyName']) -@CompileStatic -class AppTimeZone { - - /** The default timezone for App Time, for spring gets set after */ - static TimeZone APP_ZONE = TimeZone.getTimeZone("America/New_York") - - static void setTimeZone(TimeZone zone){ - APP_ZONE = zone - } - - static TimeZone getTimeZone(){ - APP_ZONE - } - - static ZoneId getZoneId(){ - APP_ZONE.toZoneId() - } - - /** - * Uses the app default zone to get the current date. - * The system zone should be set to UTC. The App can have a default time zone. - * - * So for example when I want today and its 9:00pm Eastern, its tomorrow at 1am in UTC so using the default - * LocalDate.now() give a date for tomorrow. - * - * @return the LocalDate in the default time zone. - */ - static LocalDate localDateNow() { - assert APP_ZONE - LocalDate.now(getZoneId()) - } - - static LocalDateTime localDateTimeNow() { - assert APP_ZONE - LocalDateTime.now(getZoneId()) - } - -} diff --git a/rally-domain/src/main/groovy/yakworks/rally/extensions/LocalDateTimeStaticExt.groovy b/rally-domain/src/main/groovy/yakworks/rally/extensions/LocalDateTimeStaticExt.groovy deleted file mode 100644 index 4eb13f065..000000000 --- a/rally-domain/src/main/groovy/yakworks/rally/extensions/LocalDateTimeStaticExt.groovy +++ /dev/null @@ -1,48 +0,0 @@ -/* -* Copyright 2023 Yak.Works - Licensed under the Apache License, Version 2.0 (the "License") -* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 -*/ -package yakworks.rally.extensions - -import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneId - -import groovy.transform.CompileStatic - -@CompileStatic -class LocalDateTimeStaticExt { - - /** - * Uses the app default zone to get the current date. - * The system zone should be set to UTC. The App can have a default time zone. - * - * So for example when I want today and its 9:00pm Eastern, its tomorrow at 1am in UTC so using the default - * LocalDate.now() give a date for tomorrow. - * - * @return the LocalDate in the default time zone. - */ - static LocalDate nowAppZone(final LocalDate type) { - LocalDate.now(AppTimeZone.zoneId) - } - - static LocalDateTime nowAppZone(final LocalDateTime type) { - LocalDateTime.now(AppTimeZone.zoneId) - } - - - /** - * Sister to ZoneId.systemDefault() - * Gets the App default time-zone. - * The system zone will normally be set to UTC. - * The App can have a different default time zone for where most of the users are. - * - * So for example when I want today and its 9:00pm Eastern, its tomorrow at 1am in UTC so using the default - * LocalDate.now() give a date for tomorrow. - * - * @return the LocalDate in the default time zone. - */ - static ZoneId appDefault(final ZoneId type) { - return AppTimeZone.zoneId - } -} diff --git a/rally-domain/src/main/groovy/yakworks/rally/extensions/ZonedTimeStaticExt.groovy b/rally-domain/src/main/groovy/yakworks/rally/extensions/ZonedTimeStaticExt.groovy deleted file mode 100644 index a796aa560..000000000 --- a/rally-domain/src/main/groovy/yakworks/rally/extensions/ZonedTimeStaticExt.groovy +++ /dev/null @@ -1,27 +0,0 @@ -/* -* Copyright 2023 Yak.Works - Licensed under the Apache License, Version 2.0 (the "License") -* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 -*/ -package yakworks.rally.extensions - -import java.time.ZonedDateTime - -import groovy.transform.CompileStatic - -@CompileStatic -class ZonedTimeStaticExt { - - /** - * Uses the app default zone to get the current date. - * The system zone should be set to UTC. The App can have a default time zone. - * - * So for example when I want today and its 9:00pm Eastern, its tomorrow at 1am in UTC so using the default - * ZonedDateTime.now() give a date for tomorrow. This will give now for today in the Eastern time zone. - * - * @return the LocalDate in the default time zone. - */ - static ZonedDateTime nowAppZone(final ZonedDateTime type) { - ZonedDateTime.now(AppTimeZone.zoneId) - } - -} diff --git a/rally-domain/src/main/resources/META-INF/services/org.codehaus.groovy.runtime.ExtensionModule b/rally-domain/src/main/resources/META-INF/services/org.codehaus.groovy.runtime.ExtensionModule index b3236393c..b688c364c 100644 --- a/rally-domain/src/main/resources/META-INF/services/org.codehaus.groovy.runtime.ExtensionModule +++ b/rally-domain/src/main/resources/META-INF/services/org.codehaus.groovy.runtime.ExtensionModule @@ -2,5 +2,5 @@ moduleName = rally-domain-extensions moduleVersion = ${moduleVersion} extensionClasses = yakworks.rally.extensions.CurrentUserExt,\ yakworks.rally.extensions.UserInfoExt -staticExtensionClasses = yakworks.rally.extensions.LocalDateTimeStaticExt,\ - yakworks.rally.extensions.ZonedTimeStaticExt +#staticExtensionClasses = yakworks.rally.extensions.LocalDateTimeStaticExt,\ +# yakworks.rally.extensions.ZonedTimeStaticExt diff --git a/rally-domain/src/test/groovy/yakworks/rally/common/ZonedDateUtilSpec.groovy b/rally-domain/src/test/groovy/yakworks/rally/common/ZonedDateUtilSpec.groovy deleted file mode 100644 index 8c763bbd0..000000000 --- a/rally-domain/src/test/groovy/yakworks/rally/common/ZonedDateUtilSpec.groovy +++ /dev/null @@ -1,134 +0,0 @@ -/* -* Copyright 2019 Yak.Works - Licensed under the Apache License, Version 2.0 (the "License") -* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 -*/ -package yakworks.rally.common - -import java.text.SimpleDateFormat -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.temporal.ChronoUnit - -import org.springframework.util.StringUtils - -import spock.lang.Specification - -class ZonedDateUtilSpec extends Specification { - - void "toLocalDateTimeZone"() { - when: - TimeZone.setDefault(TimeZone.getTimeZone("UTC")) - ZonedDateTime zdtUTCNow = ZonedDateTime.now() - var eastZone = ZoneId.of("America/New_York") - LocalDateTime ldEt = ZonedDateUtil.toLocalDateTimeZone(zdtUTCNow, eastZone) - - int hrsOffset = ZonedDateUtil.hoursOffset(eastZone) - - then: - //offset should be either 4 or 5, depends on whether its daylight savings. - assert hrsOffset in [-4,-5] - ZonedDateUtil.hoursBetween(zdtUTCNow.toLocalDateTime(), ldEt) == hrsOffset - //ldEt.toString() == ldNow.toString() - } - - void "toZonedDateTime"() { - when: - TimeZone.setDefault(TimeZone.getTimeZone("UTC")) - ZonedDateTime zdtUTCNow = ZonedDateTime.now() - var eastZone = ZoneId.of("America/New_York") - ZonedDateTime zdEt = ZonedDateUtil.toZonedDateTime(zdtUTCNow, eastZone) - - int hrsOffset = ZonedDateUtil.hoursOffset(eastZone) - - then: - //offset should be either 4 or 5, depends on whether its daylight savings. - assert hrsOffset in [-4,-5] - ChronoUnit.HOURS.between(zdtUTCNow.toLocalDateTime(), zdEt.toLocalDateTime()) == hrsOffset - //ldEt.toString() == ldNow.toString() - } - - void "isSameMinute isSameSecond"() { - when: - TimeZone.setDefault(TimeZone.getTimeZone("UTC")) - ZonedDateTime zdtUTCNow = ZonedDateTime.now() - var eastZone = ZoneId.of("America/New_York") - ZonedDateTime zdEt = ZonedDateUtil.toZonedDateTime(zdtUTCNow, eastZone) - - then: - ChronoUnit.MINUTES.between(zdtUTCNow, zdEt) == 0 - //ChronoUnit.MINUTES.between(zdtUTCNow.toLocalDateTime(), zdEt.toLocalDateTime()) == 0 - ZonedDateUtil.isSameMinute(zdtUTCNow, zdEt) - ZonedDateUtil.isSameSecond(zdtUTCNow, zdEt) - - } - - void "isSameMinute isSameSecond same"() { - when: - ZonedDateTime zdtTime1 = ZonedDateTime.now(ZoneId.of("America/New_York")) - ZonedDateTime zdtTime2 = ZonedDateTime.now(ZoneId.of("America/New_York")) // ZonedDateUtil.toZonedDateTime(zdtTime1, eastZone) - - then: - ZonedDateUtil.isSameMinute(zdtTime1, zdtTime2) - ZonedDateUtil.isSameSecond(zdtTime1, zdtTime2) - //but wont be equal as milliseconds are off - zdtTime1 != zdtTime2 - //unlikely this will fail but its possible if zdtTime2 is the next minute. - zdtTime1.truncatedTo(ChronoUnit.MINUTES) == zdtTime2.truncatedTo(ChronoUnit.MINUTES) - } - - void "test timezone playground"() { - setup: - //var tz = TimeZone.getDefault() - var tz1 = StringUtils.parseTimeZoneString("CST") - var utc = StringUtils.parseTimeZoneString("UTC") - - expect: - StringUtils.parseTimeZoneString("CST").toZoneId().toString() == "America/Chicago" - StringUtils.parseTimeZoneString("PST").toZoneId().toString() == "America/Los_Angeles" - // StringUtils.parseTimeZoneString("MST").toZoneId().toString() == "America/Denver" - // StringUtils.parseTimeZoneString("EST").toZoneId().toString() == "America/New_York" - utc.toZoneId() == ZoneId.of("UTC") - //tz.toZoneId() == ZoneId.of("America/Denver") - tz1.toZoneId().toString() == "America/Chicago" - LocalDateTime localDateTime = LocalDateTime.parse("2023-09-20T13:59:59") - localDateTime.toString() == "2023-09-20T13:59:59" - ZonedDateTime zonedDateTime = localDateTime.atZone(ZoneId.of("America/Chicago")) - zonedDateTime.toString() == "2023-09-20T13:59:59-05:00[America/Chicago]" - //central time - var zonedDateTime2 = LocalDateTime.parse("2023-11-06T13:59:59").atZone(ZoneId.of("America/Chicago")) - zonedDateTime2.toString() == "2023-11-06T13:59:59-06:00[America/Chicago]" - zonedDateTime2.withZoneSameInstant(ZoneId.of("UTC")).toString() == "2023-11-06T19:59:59Z[UTC]" - zonedDateTime2.withZoneSameInstant(ZoneId.of("UTC")).toLocalDateTime().toString() == "2023-11-06T19:59:59" - } - - void "local date zone conversion playground"() { - setup: - TimeZone.setDefault(TimeZone.getTimeZone("UTC")); - - expect: - ZoneId.of("America/New_York").toString() == "America/New_York" - var ldt = LocalDateTime.parse("2023-09-20T01:01:01") - ldt.toString() == "2023-09-20T01:01:01" - ldt.toDate().toString() == "Wed Sep 20 01:01:01 UTC 2023" - // LocalDateTime.ofInstant(ldt.toInstant(ZoneOffset.of())) - var utc_ldt = ldt.atZone(ZoneId.of("UTC")) - utc_ldt.toString() == "2023-09-20T01:01:01Z[UTC]" - ZonedDateTime et_ldt = utc_ldt.withZoneSameInstant(ZoneId.of("America/New_York")) - et_ldt.toString() == "2023-09-19T21:01:01-04:00[America/New_York]" - et_ldt.toLocalDateTime().toString() == "2023-09-19T21:01:01" - new SimpleDateFormat("yyMMdd").format(et_ldt.toLocalDateTime().toDate()) == "230919" - et_ldt.toLocalDateTime().toDate().toString() == "Tue Sep 19 21:01:01 UTC 2023" - //without changing to LocalDateTime first it keeps timezone and adjusts it - et_ldt.toDate().toString() == "Wed Sep 20 01:01:01 UTC 2023" - - var ldNow = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) - var ldEt = LocalDateTime.now(ZoneId.of("America/New_York")).truncatedTo(ChronoUnit.MINUTES) - - //need to handle daylight savings to get time diff between utc and nyc - ZonedDateTime nyc_time = ZonedDateTime.now( ZoneId.of( "America/New_York" ) ) - int nyc_time_diff = nyc_time.zone.rules.isDaylightSavings(nyc_time.toInstant()) ? 4 : 5 - ChronoUnit.HOURS.between(ldEt, ldNow) == nyc_time_diff - } - -} diff --git a/rally-domain/src/test/groovy/yakworks/rally/extensions/AppTimeZoneSpec.groovy b/rally-domain/src/test/groovy/yakworks/rally/extensions/AppTimeZoneSpec.groovy index 8baf1b75b..94294516b 100644 --- a/rally-domain/src/test/groovy/yakworks/rally/extensions/AppTimeZoneSpec.groovy +++ b/rally-domain/src/test/groovy/yakworks/rally/extensions/AppTimeZoneSpec.groovy @@ -12,7 +12,8 @@ import java.time.ZonedDateTime import java.time.temporal.ChronoUnit import spock.lang.Specification -import yakworks.rally.common.ZonedDateUtil +import yakworks.commons.lang.ZonedDateUtil +import yakworks.commons.extensions.AppTimeZone class AppTimeZoneSpec extends Specification { diff --git a/rally-domain/src/test/groovy/yakworks/rally/job/MaintWindowUtilSpec.groovy b/rally-domain/src/test/groovy/yakworks/rally/job/MaintWindowUtilSpec.groovy index 35a5c8e57..80e4ec387 100644 --- a/rally-domain/src/test/groovy/yakworks/rally/job/MaintWindowUtilSpec.groovy +++ b/rally-domain/src/test/groovy/yakworks/rally/job/MaintWindowUtilSpec.groovy @@ -10,7 +10,7 @@ import java.time.ZonedDateTime import spock.lang.Specification import yakworks.api.problem.ThrowableProblem -import yakworks.rally.common.ZonedDateUtil +import yakworks.commons.lang.ZonedDateUtil import yakworks.rally.config.MaintenanceProps class MaintWindowUtilSpec extends Specification { diff --git a/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/SecurityGormConfiguration.groovy b/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/SecurityGormConfiguration.groovy index cdd37a860..e380cd642 100644 --- a/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/SecurityGormConfiguration.groovy +++ b/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/SecurityGormConfiguration.groovy @@ -7,6 +7,7 @@ package yakworks.security.gorm import groovy.transform.CompileStatic import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.Configuration @@ -15,11 +16,14 @@ import org.springframework.security.core.userdetails.UserDetailsService import yakworks.gorm.api.support.QueryArgsValidator import yakworks.security.gorm.api.UserQueryArgsValidator +import yakworks.security.gorm.api.UserSecurityConfig import yakworks.security.gorm.store.GormTokenStore +import yakworks.security.spring.token.JwtProperties import yakworks.security.spring.token.store.TokenStore @ComponentScan('yakworks.security.gorm.model') //here to pick up the Repos @Configuration //(proxyBeanMethods = false) +@EnableConfigurationProperties([UserSecurityConfig]) @Lazy @CompileStatic class SecurityGormConfiguration { diff --git a/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/api/UserSecurityConfig.groovy b/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/api/UserSecurityConfig.groovy index e18bc15e7..c360fb8cb 100644 --- a/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/api/UserSecurityConfig.groovy +++ b/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/api/UserSecurityConfig.groovy @@ -14,7 +14,6 @@ import yakworks.security.user.CurrentUser @CompileStatic -@Configuration(proxyBeanMethods = false) @ConfigurationProperties(prefix="app.security") class UserSecurityConfig { diff --git a/security/boot-security/src/main/groovy/yakworks/security/spring/token/JwtProperties.groovy b/security/boot-security/src/main/groovy/yakworks/security/spring/token/JwtProperties.groovy index 1f8e2ed0e..1a96ee177 100644 --- a/security/boot-security/src/main/groovy/yakworks/security/spring/token/JwtProperties.groovy +++ b/security/boot-security/src/main/groovy/yakworks/security/spring/token/JwtProperties.groovy @@ -18,7 +18,6 @@ import org.springframework.stereotype.Component /** * Config from application.yml properteis. */ -@Component @ConfigurationProperties(prefix="app.security.jwt") @CompileStatic class JwtProperties { From 45811cf970d1f2d95ac00a4e36e9e6785f7e8e98 Mon Sep 17 00:00:00 2001 From: Sudhir Nimavat Date: Wed, 30 Apr 2025 02:04:54 +0530 Subject: [PATCH 3/7] Read only users - Secure CrudApi (#873) * POC : Read only roles - Secure CrudApi * implement POC : read only access to api * Fix tests * Fix tests * More tests * cleanup * Use delegate pattern * Fix tests * cleanup * cleanup * cleanup --------- Co-authored-by: jdabal Co-authored-by: Joshua Burnett --- .../init/yakworks/rally/api/BootStrap.groovy | 2 +- .../groovy/yakworks/SmokeRestApiSpec.groovy | 6 +- .../yakworks/rest/BulkControllerSpec.groovy | 3 +- .../groovy/yakworks/rest/BulkCsvSpec.groovy | 3 +- .../yakworks/rest/CacheListApiSpec.groovy | 2 +- .../rest/ContactControllerTests.groovy | 3 +- .../yakworks/rest/OrgControllerTests.groovy | 6 +- .../yakworks/rest/OrgRestApiSpec.groovy | 22 ++++ .../yakworks/rest/ReadonlyRestApiSpec.groovy | 84 +++++++++++++ .../security/OpaqueRestApiSpec.groovy | 20 +-- .../security/SecureCrudApiSpec.groovy | 115 ++++++++++++++++++ .../yakworks/security/TokenRestApiSpec.groovy | 5 + .../yakworks/rally/api/OrgCrudApi.groovy | 2 +- .../rally/boot/RallyApiSpringConfig.groovy | 2 + .../yakworks/security/CurrentUserSpec.groovy | 1 - .../yakworks/rest/GormRestGrailsPlugin.groovy | 2 + .../yakworks/rest/gorm/SecureCrudApi.groovy | 55 +++++++++ .../gorm/SecuredCrudApiConfiguration.groovy | 27 ++++ .../controller/BulkExceptionHandler.groovy | 12 +- .../gorm/controller/CrudApiController.groovy | 15 ++- .../integration/SecuritySpecHelper.groovy | 6 +- gorm-tools/grails-app/i18n/messages.yml | 1 + .../gorm/tools/problem/ProblemHandler.groovy | 9 +- .../activity/ActivityContactOpTests.groovy | 1 - .../gorm/testing/SecuritySeedData.groovy | 20 ++- .../spring/DefaultSecurityConfiguration.java | 19 ++- .../groovy/yakworks/security/Roles.groovy | 1 + 27 files changed, 404 insertions(+), 40 deletions(-) create mode 100644 examples/rally-api/src/integration-test/groovy/yakworks/rest/ReadonlyRestApiSpec.groovy create mode 100644 examples/rally-api/src/integration-test/groovy/yakworks/security/SecureCrudApiSpec.groovy create mode 100644 gorm-rest/src/main/groovy/yakworks/rest/gorm/SecureCrudApi.groovy create mode 100644 gorm-rest/src/main/groovy/yakworks/rest/gorm/SecuredCrudApiConfiguration.groovy diff --git a/examples/rally-api/grails-app/init/yakworks/rally/api/BootStrap.groovy b/examples/rally-api/grails-app/init/yakworks/rally/api/BootStrap.groovy index e066e14ad..87b629e61 100644 --- a/examples/rally-api/grails-app/init/yakworks/rally/api/BootStrap.groovy +++ b/examples/rally-api/grails-app/init/yakworks/rally/api/BootStrap.groovy @@ -20,7 +20,7 @@ class BootStrap { AppUser.withTransaction { AppUser.repo.flush() AppUser admin = new AppUser([ - id: (Long)6, username: "developers@9ci.com", email: "developers@9ci.com", orgId: 2 + id: 6L, username: "developers@9ci.com", email: "developers@9ci.com", orgId: 2 ]).persist() admin.addRole('ADMIN', true) admin.addRole('MANAGER', true) diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/SmokeRestApiSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/SmokeRestApiSpec.groovy index 55eff79f4..9c7c2a287 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/SmokeRestApiSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/SmokeRestApiSpec.groovy @@ -14,9 +14,9 @@ class SmokeRestApiSpec extends Specification implements OkHttpRestTrait { String path = "/api/rally" - // def setup(){ - // login() - // } + void setup(){ + login() + } // @Value('${local.server.port}') // protected Integer serverPort; diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkControllerSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkControllerSpec.groovy index 0d94e9d41..db087258c 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkControllerSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkControllerSpec.groovy @@ -4,6 +4,7 @@ package yakworks.rest import yakworks.rest.gorm.controller.CrudApiController import grails.testing.mixin.integration.Integration import org.apache.commons.lang3.StringUtils +import yakworks.testing.gorm.integration.SecuritySpecHelper import yakworks.testing.rest.RestIntTest import yakworks.rally.job.SyncJob import yakworks.rally.orgs.model.Org @@ -12,7 +13,7 @@ import static org.springframework.http.HttpStatus.MULTI_STATUS // @Rollback @Integration -class BulkControllerSpec extends RestIntTest { +class BulkControllerSpec extends RestIntTest implements SecuritySpecHelper { CrudApiController controller diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkCsvSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkCsvSpec.groovy index 1c4f45be9..711973e15 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkCsvSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkCsvSpec.groovy @@ -1,5 +1,6 @@ package yakworks.rest +import yakworks.testing.gorm.integration.SecuritySpecHelper import java.nio.file.Path @@ -18,7 +19,7 @@ import yakworks.testing.rest.RestIntTest @Rollback @Integration -class BulkCsvSpec extends RestIntTest { +class BulkCsvSpec extends RestIntTest implements SecuritySpecHelper { CrudApiController controller AttachmentRepo attachmentRepo diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/CacheListApiSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/CacheListApiSpec.groovy index 801735d6e..7d5991175 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/rest/CacheListApiSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/CacheListApiSpec.groovy @@ -35,9 +35,9 @@ class CacheListApiSpec extends Specification implements OkHttpRestTrait, WithTrx Map body = bodyToMap(resp) then: + body body.status == 429 resp.code() == HttpStatus.TOO_MANY_REQUESTS.value() - } } diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/ContactControllerTests.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/ContactControllerTests.groovy index 40b88b23d..b431c2d47 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/rest/ContactControllerTests.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/ContactControllerTests.groovy @@ -5,12 +5,13 @@ import yakworks.rest.gorm.controller.CrudApiController import grails.gorm.transactions.Rollback import grails.testing.mixin.integration.Integration import yakworks.commons.map.Maps +import yakworks.testing.gorm.integration.SecuritySpecHelper import yakworks.testing.rest.RestIntTest import yakworks.rally.orgs.model.Contact @Rollback @Integration -class ContactControllerTests extends RestIntTest { +class ContactControllerTests extends RestIntTest implements SecuritySpecHelper { CrudApiController controller // String controllerName = 'ContactController' diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgControllerTests.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgControllerTests.groovy index 9e99c280c..a8355a270 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgControllerTests.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgControllerTests.groovy @@ -1,21 +1,19 @@ package yakworks.rest import org.springframework.http.HttpStatus - - import yakworks.rally.orgs.model.ContactFlex -import yakworks.rally.orgs.model.OrgFlex import yakworks.rest.gorm.controller.CrudApiController import grails.gorm.transactions.Rollback import grails.testing.mixin.integration.Integration import yakworks.commons.map.Maps +import yakworks.testing.gorm.integration.SecuritySpecHelper import yakworks.testing.rest.RestIntTest import yakworks.rally.orgs.model.Org import yakworks.rally.tag.model.Tag @Rollback @Integration -class OrgControllerTests extends RestIntTest { +class OrgControllerTests extends RestIntTest implements SecuritySpecHelper { CrudApiController controller diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy index 1c418de33..7c45f2573 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy @@ -7,6 +7,7 @@ import okhttp3.RequestBody import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.springframework.http.HttpStatus import spock.lang.IgnoreRest +import yakworks.rest.client.OkAuth import yakworks.rest.client.OkHttpRestTrait import grails.testing.mixin.integration.Integration import okhttp3.HttpUrl @@ -415,4 +416,25 @@ class OrgRestApiSpec extends Specification implements OkHttpRestTrait, WithTrx { body.detail.contains "expecting '}'" } + void "test readonly operation"() { + setup: + OkAuth.TOKEN = null + login("readonly", "123") + + when: + String q = '{name: "Org20"}' + def resp = post(path, [num:"C1", name:"C1", type: 'Customer']) + Map body = bodyToMap(resp) + + then: + resp.code() == HttpStatus.UNAUTHORIZED.value() + body + !body.ok + body.code == "error.unauthorized" + body.title == 'Unauthorized' + body.detail == 'Access Denied' + + cleanup: + OkAuth.TOKEN = null + } } diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/ReadonlyRestApiSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/ReadonlyRestApiSpec.groovy new file mode 100644 index 000000000..2c0184645 --- /dev/null +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/ReadonlyRestApiSpec.groovy @@ -0,0 +1,84 @@ +package yakworks.rest + +import grails.testing.mixin.integration.Integration +import okhttp3.Response +import org.springframework.http.HttpStatus +import spock.lang.Specification +import yakworks.rest.client.OkAuth +import yakworks.rest.client.OkHttpRestTrait + +@Integration +class ReadonlyRestApiSpec extends Specification implements OkHttpRestTrait { + + String path = "/api/rally/contact" + + void setupSpec() { + OkAuth.TOKEN = null + } + + void cleanupSpec() { + OkAuth.TOKEN = null + } + + def setup(){ + login("readonly", "123") + } + + void "create"() { + when: + def resp = post(path, data) + + then: + assertAccessDenied(resp) + } + + void "update"() { + when: + def resp = put(path+"/1", data) + + then: + assertAccessDenied(resp) + } + + void "upsert"() { + when: + def resp = post(path+"/upsert", data) + + then: + assertAccessDenied(resp) + } + + void "remove"() { + when: + def resp = delete(path+"/1") + + then: + assertAccessDenied(resp) + } + + void "bulk"() { + when: + def resp = post(path+"/bulk?jobSource=Oracle&savePayload=false", [data]) + + then: + assertAccessDenied(resp) + } + + void assertAccessDenied(Response resp) { + assert resp + assert resp.code() == HttpStatus.UNAUTHORIZED.value() + + Map body = bodyToMap(resp) + + assert body + assert !body.ok + assert body.code == "error.unauthorized" + assert body.title == 'Unauthorized' + assert body.detail == 'Access Denied' + } + + Map getData() { + return [name: "C1", firstName: "C1", orgId: 2,] + //return [num:"T1", name:"T1", type: OrgType.Customer.name()] + } +} diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/security/OpaqueRestApiSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/security/OpaqueRestApiSpec.groovy index ae3667fb8..7617421af 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/security/OpaqueRestApiSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/security/OpaqueRestApiSpec.groovy @@ -1,7 +1,6 @@ package yakworks.security import java.time.Instant -import java.time.LocalDate import java.time.LocalDateTime import org.springframework.beans.factory.annotation.Autowired @@ -9,15 +8,14 @@ import org.springframework.http.HttpStatus import org.springframework.security.oauth2.core.OAuth2AccessToken import grails.testing.mixin.integration.Integration -import spock.lang.Ignore import spock.lang.Specification import yakworks.rest.client.OkAuth import yakworks.rest.client.OkHttpRestTrait -import yakworks.security.gorm.model.AppUserToken import yakworks.security.spring.token.store.TokenStore -// @Ignore +import java.time.ZoneId + @Integration class OpaqueRestApiSpec extends Specification implements OkHttpRestTrait { @@ -27,7 +25,10 @@ class OpaqueRestApiSpec extends Specification implements OkHttpRestTrait { def setup(){ OkAuth.TOKEN = "opq_123" - //OkAuth.BEARER_TOKEN = "Bearer opq_123" + } + + void cleanupSpec() { + OkAuth.TOKEN = null } OAuth2AccessToken createOAuthToken(String tokenValue, Instant nowTime, Instant expireAt){ @@ -42,13 +43,15 @@ class OpaqueRestApiSpec extends Specification implements OkHttpRestTrait { void "test get to make sure display false dont get returned"() { setup: - // AppUserToken.create([username: 'admin', tokenValue: 'opq_123', expiresAt: LocalDateTime.now().plusDays(2)], flush: true) //add token to the store. - def oat = createOAuthToken("opq_123", Instant.now(), Instant.now().plusSeconds(30)) + LocalDateTime now = LocalDateTime.now() + Instant nowInstant = now.atZone(ZoneId.of("UTC")).toInstant() + def oat = createOAuthToken("opq_123", nowInstant, nowInstant.plusSeconds(20)) tokenStore.storeToken('admin', oat) when: def resp = get("$endpoint/1") + assert resp.code() == 200 Map body = bodyToMap(resp) then: @@ -57,6 +60,9 @@ class OpaqueRestApiSpec extends Specification implements OkHttpRestTrait { //shoudl not have the display:false fields !body.containsKey('passwordHash') !body.containsKey('resetPasswordToken') + + cleanup: + tokenStore.removeToken('opq_123') } } diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/security/SecureCrudApiSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/security/SecureCrudApiSpec.groovy new file mode 100644 index 000000000..16c56ac05 --- /dev/null +++ b/examples/rally-api/src/integration-test/groovy/yakworks/security/SecureCrudApiSpec.groovy @@ -0,0 +1,115 @@ +package yakworks.security + +import gorm.tools.repository.model.DataOp +import gorm.tools.utils.ServiceLookup +import grails.testing.mixin.integration.Integration +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.core.AuthenticationException +import spock.lang.Specification +import yakworks.gorm.api.DefaultCrudApi +import yakworks.rally.api.OrgCrudApi +import yakworks.rally.orgs.model.Org +import yakworks.rally.orgs.model.OrgType +import yakworks.rest.gorm.SecureCrudApi +import yakworks.gorm.api.CrudApi +import yakworks.spring.AppCtx + +import javax.inject.Inject + +@Integration +class SecureCrudApiSpec extends Specification { + + @Inject OrgCrudApi orgCrudApi + @Inject SecService service + + SecureCrudApi orgSecureCrudApi + + void setup() { + orgSecureCrudApi = AppCtx.ctx.getBean("secureCrudApi", [Org] as Object[]) + } + + void "sanity check"() { + expect: + orgCrudApi + orgCrudApi instanceof DefaultCrudApi + orgSecureCrudApi + orgSecureCrudApi instanceof SecureCrudApi + } + + void "not logged in"() { + when: + orgSecureCrudApi.create(orgData, [:]) + + then: + AuthenticationException ex = thrown() + + when: + orgSecureCrudApi.update(orgData, [:]) + + then: + ex = thrown() + + when: + orgSecureCrudApi.upsert(orgData, [:]) + + then: + ex = thrown() + + when: + orgSecureCrudApi.removeById(1L, [:]) + + then: + ex = thrown() + + when: + orgSecureCrudApi.bulk(DataOp.update, [orgData], [:], "Test") + + then: + ex = thrown() + } + + void "readonly user"() { + setup: + service.login("readonly", "123") + + when: + orgSecureCrudApi.create(orgData, [:]) + + then: + AccessDeniedException ex = thrown() + ex.message == 'Access Denied' + + when: + orgSecureCrudApi.update(orgData, [:]) + + then: + ex = thrown() + ex.message == 'Access Denied' + + when: + orgSecureCrudApi.upsert(orgData, [:]) + + then: + ex = thrown() + ex.message == 'Access Denied' + + when: + orgSecureCrudApi.removeById(1L, [:]) + + then: + ex = thrown() + ex.message == 'Access Denied' + + when: + orgSecureCrudApi.bulk(DataOp.update, [orgData], [:], "Test") + + then: + ex = thrown() + ex.message == 'Access Denied' + } + + Map getOrgData() { + return [num:"T1", name:"T1", type: OrgType.Customer.name()] + } + +} diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/security/TokenRestApiSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/security/TokenRestApiSpec.groovy index dd48f2419..803bb3dff 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/security/TokenRestApiSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/security/TokenRestApiSpec.groovy @@ -17,9 +17,14 @@ import yakworks.rest.client.OkHttpRestTrait class TokenRestApiSpec extends Specification implements OkHttpRestTrait { def setup(){ + OkAuth.TOKEN = null login() } + void cleanupSpec() { + OkAuth.TOKEN = null + } + Response doTokenPost(Map params){ // Initialize Builder (not RequestBody) FormBody.Builder builder = new FormBody.Builder() diff --git a/examples/rally-api/src/main/groovy/yakworks/rally/api/OrgCrudApi.groovy b/examples/rally-api/src/main/groovy/yakworks/rally/api/OrgCrudApi.groovy index 98928f960..7d26d55be 100644 --- a/examples/rally-api/src/main/groovy/yakworks/rally/api/OrgCrudApi.groovy +++ b/examples/rally-api/src/main/groovy/yakworks/rally/api/OrgCrudApi.groovy @@ -18,6 +18,7 @@ import yakworks.commons.map.Maps import yakworks.gorm.api.DefaultCrudApi import yakworks.gorm.api.IncludesKey import yakworks.rally.orgs.model.Org +import yakworks.rest.gorm.SecureCrudApi import yakworks.security.user.CurrentUser @Slf4j @@ -46,7 +47,6 @@ class OrgCrudApi extends DefaultCrudApi { key="{@currentUser.getUserId(), #qParams.toString(), #root.target.entityClass.simpleName}", sync=true ) - //" + #includesKeys.toString()") @Override Pager list(Map qParams, URI uri){ log.debug("********************* list no cache hit") diff --git a/examples/rally-api/src/main/groovy/yakworks/rally/boot/RallyApiSpringConfig.groovy b/examples/rally-api/src/main/groovy/yakworks/rally/boot/RallyApiSpringConfig.groovy index a47bc32b2..8cabeb025 100644 --- a/examples/rally-api/src/main/groovy/yakworks/rally/boot/RallyApiSpringConfig.groovy +++ b/examples/rally-api/src/main/groovy/yakworks/rally/boot/RallyApiSpringConfig.groovy @@ -14,6 +14,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import import org.springframework.context.annotation.Lazy +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.http.SessionCreationPolicy @@ -38,6 +39,7 @@ import static org.springframework.security.config.Customizer.withDefaults /** * An example of explicitly configuring Spring Security with the defaults. */ +@EnableMethodSecurity @EnableWebSecurity //(debug = true) @CompileStatic @Configuration diff --git a/examples/testify/src/integration-test/groovy/yakworks/security/CurrentUserSpec.groovy b/examples/testify/src/integration-test/groovy/yakworks/security/CurrentUserSpec.groovy index 3e157f976..cf08909f6 100644 --- a/examples/testify/src/integration-test/groovy/yakworks/security/CurrentUserSpec.groovy +++ b/examples/testify/src/integration-test/groovy/yakworks/security/CurrentUserSpec.groovy @@ -118,7 +118,6 @@ class CurrentUserSpec extends Specification implements DomainIntTest { new SecUserPermission(user3, 'action:kick').persist() - new SecRolePermission(roleAdmin, 'printer:admin').persist() new SecRolePermission(roleUser, 'printer:use').persist() diff --git a/gorm-rest/src/main/groovy/yakworks/rest/GormRestGrailsPlugin.groovy b/gorm-rest/src/main/groovy/yakworks/rest/GormRestGrailsPlugin.groovy index 463c93587..a0fb3ed70 100644 --- a/gorm-rest/src/main/groovy/yakworks/rest/GormRestGrailsPlugin.groovy +++ b/gorm-rest/src/main/groovy/yakworks/rest/GormRestGrailsPlugin.groovy @@ -8,6 +8,7 @@ package yakworks.rest import org.grails.datastore.gorm.validation.constraints.registry.DefaultConstraintRegistry import grails.plugins.Plugin +import yakworks.rest.gorm.SecuredCrudApiConfiguration import yakworks.rest.gorm.mapping.RepoApiMappingsService import yakworks.rest.gorm.render.ApiResultsRenderer import yakworks.rest.gorm.render.CSVPagerRenderer @@ -48,6 +49,7 @@ class GormRestGrailsPlugin extends Plugin { csvPagerRenderer(CSVPagerRenderer) xlsxPagerRenderer(XlsxPagerRenderer) + secureCrudApiConfiguration(SecuredCrudApiConfiguration) } } } diff --git a/gorm-rest/src/main/groovy/yakworks/rest/gorm/SecureCrudApi.groovy b/gorm-rest/src/main/groovy/yakworks/rest/gorm/SecureCrudApi.groovy new file mode 100644 index 000000000..1db77987e --- /dev/null +++ b/gorm-rest/src/main/groovy/yakworks/rest/gorm/SecureCrudApi.groovy @@ -0,0 +1,55 @@ +/* +* Copyright 2025 Yak.Works - Licensed under the Apache License, Version 2.0 (the "License") +* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +*/ +package yakworks.rest.gorm + +import groovy.transform.CompileStatic + +import org.springframework.security.access.prepost.PreAuthorize + +import gorm.tools.job.SyncJobEntity +import gorm.tools.repository.model.DataOp +import yakworks.gorm.api.CrudApi + +@CompileStatic +class SecureCrudApi implements CrudApi { + + @Delegate + CrudApi defaultCrudApi + + SecureCrudApi(CrudApi defaultCrudApi) { + this.defaultCrudApi = defaultCrudApi + } + + @Override + @PreAuthorize("!hasRole('ROLE_READ_ONLY')") + CrudApiResult create(Map data, Map params) { + return defaultCrudApi.create(data, params) + } + + @Override + @PreAuthorize("!hasRole('ROLE_READ_ONLY')") + CrudApiResult update(Map data, Map params) { + return defaultCrudApi.update(data, params) + } + + @Override + @PreAuthorize("!hasRole('ROLE_READ_ONLY')") + CrudApiResult upsert(Map data, Map params) { + return defaultCrudApi.upsert(data, params) + } + + @Override + @PreAuthorize("!hasRole('ROLE_READ_ONLY')") + void removeById(Serializable id, Map params) { + defaultCrudApi.removeById(id, params) + } + + @Override + @PreAuthorize("!hasRole('ROLE_READ_ONLY')") + SyncJobEntity bulk(DataOp dataOp, List dataList, Map params, String sourceId) { + return defaultCrudApi.bulk(dataOp, dataList, params, sourceId) + } + +} diff --git a/gorm-rest/src/main/groovy/yakworks/rest/gorm/SecuredCrudApiConfiguration.groovy b/gorm-rest/src/main/groovy/yakworks/rest/gorm/SecuredCrudApiConfiguration.groovy new file mode 100644 index 000000000..294e07c36 --- /dev/null +++ b/gorm-rest/src/main/groovy/yakworks/rest/gorm/SecuredCrudApiConfiguration.groovy @@ -0,0 +1,27 @@ +/* +* Copyright 2024 Yak.Works - Licensed under the Apache License, Version 2.0 (the "License") +* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +*/ +package yakworks.rest.gorm + +import groovy.transform.CompileStatic + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Scope + +import gorm.tools.utils.ServiceLookup +import yakworks.gorm.api.CrudApi + +@CompileStatic +@Configuration +class SecuredCrudApiConfiguration { + + @Bean + @Scope("prototype") + SecureCrudApi secureCrudApi(Class entityClass) { + CrudApi defaultCrudApi = ServiceLookup.lookup(entityClass, CrudApi, "defaultCrudApi") + return new SecureCrudApi(defaultCrudApi) + } + +} diff --git a/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/BulkExceptionHandler.groovy b/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/BulkExceptionHandler.groovy index e879062fc..4ac6016e4 100644 --- a/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/BulkExceptionHandler.groovy +++ b/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/BulkExceptionHandler.groovy @@ -9,6 +9,8 @@ import javax.servlet.http.HttpServletRequest import groovy.transform.CompileStatic import groovy.util.logging.Slf4j +import org.springframework.security.access.AccessDeniedException + import gorm.tools.problem.ProblemHandler import yakworks.api.problem.Problem @@ -27,7 +29,7 @@ class BulkExceptionHandler { this.entityClass = entityClass } - public static BulkExceptionHandler of(Class entityClass, ProblemHandler problemHandler){ + static BulkExceptionHandler of(Class entityClass, ProblemHandler problemHandler){ def bcs = new BulkExceptionHandler(entityClass) bcs.problemHandler = problemHandler return bcs @@ -38,7 +40,13 @@ class BulkExceptionHandler { * Its here, because we cant have more thn one exception handler for "Exception" in controller */ Problem handleBulkOperationException(HttpServletRequest req, Throwable e) { - Problem apiError = problemHandler.handleException(getEntityClass(), e) + Problem apiError + if(e instanceof AccessDeniedException) { + apiError = ProblemHandler.handleAccessDenied(e) + } + else { + apiError = problemHandler.handleException(getEntityClass(), e) + } if (apiError.status.code == 500) { String requestInfo = "requestURI=[${req.requestURI}], method=[${req.method}], queryString=[${req.queryString}]" log.warn("⛔️ 👉 Bulk operation exception ⛔️ \n $requestInfo \n $apiError.cause?.message") diff --git a/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy b/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy index 3d008e9da..b919ef7a0 100644 --- a/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy +++ b/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy @@ -6,7 +6,6 @@ package yakworks.rest.gorm.controller import java.net.http.HttpRequest import java.nio.charset.StandardCharsets -import java.util.concurrent.TimeoutException import java.util.function.Function import javax.persistence.LockTimeoutException import javax.servlet.http.HttpServletRequest @@ -18,17 +17,19 @@ import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.core.GenericTypeResolver import org.springframework.http.HttpStatus +import org.springframework.security.access.AccessDeniedException import org.springframework.web.util.UriUtils import gorm.tools.beans.Pager import gorm.tools.job.SyncJobEntity +import gorm.tools.problem.ProblemHandler import gorm.tools.repository.model.DataOp -import gorm.tools.utils.ServiceLookup import grails.web.Action import yakworks.api.problem.Problem import yakworks.etl.csv.CsvToMapTransformer import yakworks.gorm.api.CrudApi import yakworks.gorm.api.IncludesProps +import yakworks.spring.AppCtx import static gorm.tools.problem.ProblemHandler.isBrokenPipe import static org.springframework.http.HttpStatus.CREATED @@ -91,7 +92,10 @@ trait CrudApiController extends RestApiController { CrudApi getCrudApi(){ if (!crudApi) { - this.crudApi = ServiceLookup.lookup(getEntityClass(), CrudApi, "defaultCrudApi") + //not using ServiceLookup.lookup as we dont want to inject OrgCrudApi, even if its available, + //Always inject SecureCrudApi, which wraps and delegates to configured custom CrudApi or DefaultCrudApi. + this.crudApi = (CrudApi)AppCtx.ctx.getBean("secureCrudApi", [getEntityClass()] as Object[]) + //this.crudApi = ServiceLookup.lookup(getEntityClass(), CrudApi, "secureCrudApi") //this.crudApi = crudApiFactory.apply(getEntityClass()) //this.crudApi = crudApiClosure.call(getEntityClass()) as CrudApi // try { @@ -334,7 +338,10 @@ trait CrudApiController extends RestApiController { //do the rest Problem apiError - if(e instanceof LockTimeoutException){ + if(e instanceof AccessDeniedException) { + apiError = ProblemHandler.handleAccessDenied(e) + } + else if(e instanceof LockTimeoutException){ //thrown from locking in hazelcast cache apiError = Problem.of('error.query.duplicate') .detail("Timeout while waiting for 1 or more duplicate identical queries to finish for this user") diff --git a/gorm-test-support/src/main/groovy/yakworks/testing/gorm/integration/SecuritySpecHelper.groovy b/gorm-test-support/src/main/groovy/yakworks/testing/gorm/integration/SecuritySpecHelper.groovy index 6c706d3d4..130a69e48 100644 --- a/gorm-test-support/src/main/groovy/yakworks/testing/gorm/integration/SecuritySpecHelper.groovy +++ b/gorm-test-support/src/main/groovy/yakworks/testing/gorm/integration/SecuritySpecHelper.groovy @@ -6,13 +6,11 @@ package yakworks.testing.gorm.integration import groovy.transform.CompileDynamic +import org.junit.Before import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.context.SecurityContextHolder import gorm.tools.transaction.WithTrx -import grails.testing.spock.OnceBefore import yakworks.security.SecService import yakworks.security.gorm.model.AppUser import yakworks.security.spring.user.SpringUser @@ -31,7 +29,7 @@ trait SecuritySpecHelper implements WithTrx{ @Autowired CurrentUser currentUser //need to name it like this, otherwise subclasses cant use setupSpec method - @OnceBefore + @Before void setupSecuritySpec() { withTrx { secService.loginAsSystemUser() diff --git a/gorm-tools/grails-app/i18n/messages.yml b/gorm-tools/grails-app/i18n/messages.yml index 4ceb1785a..24af8a9d1 100644 --- a/gorm-tools/grails-app/i18n/messages.yml +++ b/gorm-tools/grails-app/i18n/messages.yml @@ -5,6 +5,7 @@ error: notFound: '{name} lookup failed using key {key}' illegalArgument: Illegal Argument Exception notupdateable: '{name} can not be updated' + unauthorized: 'Unauthorized' # DATA data: diff --git a/gorm-tools/src/main/groovy/gorm/tools/problem/ProblemHandler.groovy b/gorm-tools/src/main/groovy/gorm/tools/problem/ProblemHandler.groovy index aaff336dc..1d5305e1a 100644 --- a/gorm-tools/src/main/groovy/gorm/tools/problem/ProblemHandler.groovy +++ b/gorm-tools/src/main/groovy/gorm/tools/problem/ProblemHandler.groovy @@ -30,7 +30,6 @@ import yakworks.api.problem.data.DataProblem import yakworks.api.problem.data.DataProblemCodes import yakworks.i18n.icu.ICUMessageSource import yakworks.message.MsgServiceRegistry - /** * Service to prepare ApiError / ApiValidationError for given a given exception * @@ -68,6 +67,7 @@ class ProblemHandler { ApiStatus status400 = HttpStatus.BAD_REQUEST ApiStatus status404 = HttpStatus.NOT_FOUND ApiStatus status422 = HttpStatus.UNPROCESSABLE_ENTITY + ApiStatus status401 = HttpStatus.UNAUTHORIZED if (e instanceof ValidationProblem.Exception) { def valProblem = e.getValidationProblem() @@ -216,6 +216,13 @@ class ProblemHandler { return ex.message && ex.message.toLowerCase().contains("broken pipe") } + /** + * Handles Access denied exception, which is thrown when user doesnt have required roles/authority to access a method + */ + static GenericProblem handleAccessDenied(Exception ex) { + return Problem.of('error.unauthorized').status(HttpStatus.UNAUTHORIZED).detail(ex.message) + } + //Legacy from ValidationException static String formatErrors(Errors errors, String msg) { String ls = System.getProperty("line.separator"); diff --git a/rally-domain/src/integration-test/groovy/yakworks/rally/activity/ActivityContactOpTests.groovy b/rally-domain/src/integration-test/groovy/yakworks/rally/activity/ActivityContactOpTests.groovy index 2eb30dcce..d744097b7 100644 --- a/rally-domain/src/integration-test/groovy/yakworks/rally/activity/ActivityContactOpTests.groovy +++ b/rally-domain/src/integration-test/groovy/yakworks/rally/activity/ActivityContactOpTests.groovy @@ -1,6 +1,5 @@ package yakworks.rally.activity -import yakworks.json.groovy.JsonEngine import yakworks.testing.gorm.integration.SecuritySpecHelper import yakworks.testing.gorm.integration.DataIntegrationTest import grails.gorm.transactions.Rollback diff --git a/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/testing/SecuritySeedData.groovy b/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/testing/SecuritySeedData.groovy index 3e72b66ed..d3ef85284 100644 --- a/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/testing/SecuritySeedData.groovy +++ b/security/boot-security-gorm/src/main/groovy/yakworks/security/gorm/testing/SecuritySeedData.groovy @@ -22,14 +22,14 @@ class SecuritySeedData { static void createAppUsers(){ AppUser admin = new AppUser([ - id: (Long)1, username: "admin", email: "admin@yak.com", password:"123", orgId: 2 + id: 1L, username: "admin", email: "admin@yak.com", password:"123", orgId: 2 ]).persist() admin.addRole(Roles.ADMIN, true) admin.addRole(Roles.MANAGER, true) AppUser custUser = new AppUser([ - id: (Long)2, username: "cust", email: "cust@yak.com", password:"123", orgId: 2 + id: 2L, username: "cust", email: "cust@yak.com", password:"123", orgId: 2 ]).persist() assert custUser.id == 2 @@ -40,15 +40,23 @@ class SecuritySeedData { [bindId: true] ) assert noRoleUser.id == 3 + + AppUser readonlyUser = new AppUser([ + id: 4L, username: "readonly", email: "readonly@yak.com", password:"123", orgId: 2 + ]).persist() + assert readonlyUser.id == 4 + readonlyUser.addRole(Roles.READ_ONLY, true) + SecRole.repo.flush() } static void createRoles(){ - SecRole admin = new SecRole(id: (Long)1, code: Roles.ADMIN).persist() - SecRole power = new SecRole(id: (Long)2, code: Roles.POWER_USER).persist() - SecRole mgr = new SecRole(id: (Long)3, code: Roles.MANAGER).persist() - SecRole custRole = new SecRole(id: (Long)5, code: Roles.CUSTOMER).persist() + SecRole admin = new SecRole(id: 1L, code: Roles.ADMIN).persist() + SecRole power = new SecRole(id: 2L, code: Roles.POWER_USER).persist() + SecRole mgr = new SecRole(id: 3L, code: Roles.MANAGER).persist() + SecRole custRole = new SecRole(id: 5L, code: Roles.CUSTOMER).persist() + SecRole readonly = new SecRole(id: 6L, code: Roles.READ_ONLY).persist() // SecRole foo = new SecRole(id: (Long)6, code: "FOO").persist() //add permissions diff --git a/security/boot-security/src/main/groovy/yakworks/security/spring/DefaultSecurityConfiguration.java b/security/boot-security/src/main/groovy/yakworks/security/spring/DefaultSecurityConfiguration.java index 4c2635d94..17b86a0c8 100644 --- a/security/boot-security/src/main/groovy/yakworks/security/spring/DefaultSecurityConfiguration.java +++ b/security/boot-security/src/main/groovy/yakworks/security/spring/DefaultSecurityConfiguration.java @@ -1,6 +1,8 @@ package yakworks.security.spring; import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import yakworks.security.SecService; import yakworks.security.services.PasswordValidator; import yakworks.security.spring.token.CookieAuthSuccessHandler; @@ -118,7 +120,8 @@ public static void applyOauthJwt(HttpSecurity http) throws Exception { // http.csrf((csrf) -> csrf.ignoringAntMatchers("/token")) http.csrf().disable(); http.oauth2ResourceServer((oauth2) -> { - oauth2.jwt(); + oauth2.jwt() + .jwtAuthenticationConverter(jwtAuthenticationConverter()); // oauth2.authenticationManagerResolver(authenticationManagerResolver); }); // .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) @@ -128,6 +131,20 @@ public static void applyOauthJwt(HttpSecurity http) throws Exception { // ); } + /** + * JwtAuthenticationConverter that grabs roles/authorities from jwt token. + * By default, JwtGrantedAuthoritiesConverter would put `scope_` prefix for every role. eg SCOPE_ROLE_READOLY + * and hence it wont match expressions such as "hasRole("ROLE_READ_ONLY") + * its here, mainly to set setAuthorityPrefix to empty string + */ + static private JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); + jwtGrantedAuthoritiesConverter.setAuthorityPrefix(""); + JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter); + return jwtAuthenticationConverter; + } + /** * Default securityFilterChain. Helper to set up HttpSecurity builder with default requestMatchers and forms. * NOTE: this is more of an example and a common simple setup for smoke test apps to use. diff --git a/security/security-core/src/main/groovy/yakworks/security/Roles.groovy b/security/security-core/src/main/groovy/yakworks/security/Roles.groovy index 025977a58..ac6fb2bb8 100644 --- a/security/security-core/src/main/groovy/yakworks/security/Roles.groovy +++ b/security/security-core/src/main/groovy/yakworks/security/Roles.groovy @@ -22,6 +22,7 @@ class Roles { static final String GUEST = "GUEST" // single customer user static final String CUSTOMER = "CUSTOMER" + static final String READ_ONLY = "READ_ONLY" //FIXME move into domain9 when we move over domain9 logic from RallyUserService static final String AR_MANAGER = "AR_MANAGER" //can see other collector tasks, approvals From 06ce6d3d529c431bdd10bae12cad14c71fa0c6d8 Mon Sep 17 00:00:00 2001 From: Sudhir Nimavat Date: Wed, 30 Apr 2025 02:05:59 +0530 Subject: [PATCH 4/7] Bulk export (#877) * POC : Bulk export * wip * wip : Add BulkExportService * spotless * comments * cleanup * cleanup crudapi * test * Remove ignore * paginate data * fix test --------- Co-authored-by: Joshua B --- .../yakworks/rest/BulkRestApiSpec.groovy | 13 +- .../testify/grails-app/conf/application.yml | 1 + .../BulkExportServiceIntegrationSpec.groovy | 143 +++++++++++++++ .../gorm/controller/CrudApiController.groovy | 14 ++ .../mapping/RepoApiMappingsService.groovy | 7 + .../groovy/gorm/tools/job/SyncJobArgs.groovy | 6 + .../gorm/tools/job/SyncJobContext.groovy | 2 +- .../gorm/tools/repository/GormRepo.groovy | 2 +- .../repository/bulk/BulkExportService.groovy | 166 ++++++++++++++++++ .../tools/repository/bulk/BulkableRepo.groovy | 2 +- .../tools/repository/model/ApiCrudRepo.groovy | 1 - .../groovy/yakworks/gorm/api/CrudApi.groovy | 2 + .../yakworks/gorm/api/DefaultCrudApi.groovy | 5 + .../gorm/api/support/BulkApiSupport.groovy | 22 ++- .../gorm/boot/GormToolsConfiguration.groovy | 7 + 15 files changed, 387 insertions(+), 6 deletions(-) create mode 100644 examples/testify/src/integration-test/groovy/gorm/tools/repository/BulkExportServiceIntegrationSpec.groovy create mode 100644 gorm-tools/src/main/groovy/gorm/tools/repository/bulk/BulkExportService.groovy diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkRestApiSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkRestApiSpec.groovy index 6336e04ca..056d91bb7 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkRestApiSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/BulkRestApiSpec.groovy @@ -5,7 +5,6 @@ import org.apache.commons.lang3.StringUtils import org.springframework.http.HttpStatus import gorm.tools.job.SyncJobState - import yakworks.rest.client.OkHttpRestTrait import grails.testing.mixin.integration.Integration import okhttp3.Response @@ -210,4 +209,16 @@ class BulkRestApiSpec extends Specification implements OkHttpRestTrait { inserted == 1 updated == 3 } + + void "bulk export"() { + when: + Response resp = get("/api/rally/org/bulk?q=*¶llel=false&async=false") + Map body = bodyToMap(resp) + + then: + body + body.id + body.state == SyncJobState.Queued.name() + body.sourceId + } } diff --git a/examples/testify/grails-app/conf/application.yml b/examples/testify/grails-app/conf/application.yml index e155d24fe..e7a4a0845 100644 --- a/examples/testify/grails-app/conf/application.yml +++ b/examples/testify/grails-app/conf/application.yml @@ -33,6 +33,7 @@ app: resources: rootLocation: "${project.rootProjectDir}/examples/resources" tempDir: "./build/rootLocation/tempDir" + attachments.location: 'attachments' yakworks: gorm: diff --git a/examples/testify/src/integration-test/groovy/gorm/tools/repository/BulkExportServiceIntegrationSpec.groovy b/examples/testify/src/integration-test/groovy/gorm/tools/repository/BulkExportServiceIntegrationSpec.groovy new file mode 100644 index 000000000..b5259203a --- /dev/null +++ b/examples/testify/src/integration-test/groovy/gorm/tools/repository/BulkExportServiceIntegrationSpec.groovy @@ -0,0 +1,143 @@ +package gorm.tools.repository + +import gorm.tools.job.SyncJobArgs +import gorm.tools.job.SyncJobContext +import gorm.tools.job.SyncJobState +import gorm.tools.repository.bulk.BulkExportService +import grails.gorm.transactions.Rollback +import grails.testing.mixin.integration.Integration +import spock.lang.Specification +import yakworks.rally.attachment.model.Attachment +import yakworks.rally.job.SyncJob +import yakworks.rally.orgs.model.Org +import yakworks.testing.gorm.integration.DomainIntTest + +import javax.inject.Inject + +import static yakworks.json.groovy.JsonEngine.parseJson + +@Integration +@Rollback +class BulkExportServiceIntegrationSpec extends Specification implements DomainIntTest { + + @Inject BulkExportService bulkExportService + + void "test scheduleBulkExportJob"() { + setup: + SyncJobArgs args = setupJobArgs("type": "Company", "inactive":false) + + when: + Long jobId = bulkExportService.scheduleBulkExportJob(args) + + then: + noExceptionThrown() + jobId + + when: + SyncJob job = SyncJob.get(jobId) + + then: + job + job.state == SyncJobState.Queued + + when: + String payload = job.payloadToString() + + then: + payload + + when: + def payloadJson = parseJson(payload) + + then: + payloadJson + payloadJson.q == ["type": "Company", "inactive":false] + payloadJson.includes == ["id", "num", "name"] + } + + void "test buildJobContext and args "() { + setup: + Long jobId = bulkExportService.scheduleBulkExportJob(setupJobArgs("type": "Company", "inactive":false)) + + when: + SyncJobContext context = bulkExportService.buildJobContext(jobId) + SyncJobArgs args = context.args + + then: + noExceptionThrown() + context + context.syncJobService + args + args.queryArgs + args.queryArgs.criteriaMap == ["type": "Company", "inactive":false] + args.includes == ["id", "num", "name"] + args.saveDataAsFile + args.async + args.parallel + } + + void "run export"() { + setup: + Long jobId = bulkExportService.scheduleBulkExportJob(setupJobArgs("inactive": false)) + + when: + bulkExportService.runBulkExportJob(jobId, false) + flushAndClear() + + then: + noExceptionThrown() + + when: + SyncJob job = SyncJob.repo.getWithTrx(jobId) + + + then: + job + job.ok + job.dataId //should have attachment + Attachment.repo.exists(job.dataId) + + when: + String jsonStr = job.dataToString() + def json = parseJson(jsonStr) + + then: + json + json instanceof List + json.size() == Org.findAllWhere(inactive:false).size() / 10 + json[0].data instanceof List + + when: + List data = json[0].data + + then: + //syncjob data format + /* + * { + * data: [ + * {id:1, name:"x", num:"y"}, + * {id:2, name:"x", num:"y"}, + * ] + * } + */ + data.size() == 10 // + data[0].id == 1 + data[0].num + data[0].name + + + cleanup: + if(job && job.dataId) { + Attachment.repo.removeById(job.dataId) + } + } + + SyncJobArgs setupJobArgs(Map q) { + SyncJobArgs syncJobArgs = SyncJobArgs.withParams(q:q) + syncJobArgs.includes = ["id", "num", "name"] + syncJobArgs.jobState = SyncJobState.Queued + syncJobArgs.entityClass = Org + return syncJobArgs + } + +} diff --git a/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy b/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy index b919ef7a0..e5cf64985 100644 --- a/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy +++ b/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy @@ -71,6 +71,7 @@ trait CrudApiController extends RestApiController { @Autowired private Function crudApiFactory + // @Autowired // Closure crudApiClosure @@ -248,6 +249,19 @@ trait CrudApiController extends RestApiController { } } + @Action + def bulkExport() { + try { + Map qParams = getParamsMap() + SyncJobEntity job = getCrudApi().bulkExport(qParams, requestToSourceId(request)) + respondWith(job, [status: MULTI_STATUS]) + } catch (Exception | AssertionError e) { + respondWith( + BulkExceptionHandler.of(getEntityClass(), problemHandler).handleBulkOperationException(request, e) + ) + } + } + void bulkProcess(DataOp dataOp) { List dataList = bodyAsList() as List Map qParams = getParamsMap() diff --git a/gorm-rest/src/main/groovy/yakworks/rest/gorm/mapping/RepoApiMappingsService.groovy b/gorm-rest/src/main/groovy/yakworks/rest/gorm/mapping/RepoApiMappingsService.groovy index 7227c4b24..25259c017 100644 --- a/gorm-rest/src/main/groovy/yakworks/rest/gorm/mapping/RepoApiMappingsService.groovy +++ b/gorm-rest/src/main/groovy/yakworks/rest/gorm/mapping/RepoApiMappingsService.groovy @@ -61,12 +61,19 @@ class RepoApiMappingsService { .httpMethod('PUT').action('bulkUpdate').suffix('/bulk') .urlMappingBuilder(builderDelegate).build() + //bulk export FIXME @SUD, not working, doesnt get picked up + SimpleUrlMappingBuilder.of(contextPath, nspace, ctrlName) + .httpMethod('GET').action('bulkExport').suffix('/bulk') + .urlMappingBuilder(builderDelegate).build() + + //allow POST any action added to the controller // /api/nspace/controller/$action // post "/$action(.$format)?"(controller: cName) SimpleUrlMappingBuilder.of(contextPath, nspace, ctrlName) .httpMethod('POST').suffix('/(*)').matchParams(['action']) .urlMappingBuilder(builderDelegate).build() + } } } diff --git a/gorm-tools/src/main/groovy/gorm/tools/job/SyncJobArgs.groovy b/gorm-tools/src/main/groovy/gorm/tools/job/SyncJobArgs.groovy index db41a195a..60a1b16e1 100644 --- a/gorm-tools/src/main/groovy/gorm/tools/job/SyncJobArgs.groovy +++ b/gorm-tools/src/main/groovy/gorm/tools/job/SyncJobArgs.groovy @@ -159,6 +159,12 @@ class SyncJobArgs { //reference back to the SyncJobContext built from these args. SyncJobContext context + /** + * SyncJobState to use when creating new job. + * Default is Running. But Queued can be used for jobs which are scheduled to run later, eg BulkExport. + */ + SyncJobState jobState = SyncJobState.Running + /** helper to return true if op=DataOp.add */ boolean isCreate(){ op == DataOp.add diff --git a/gorm-tools/src/main/groovy/gorm/tools/job/SyncJobContext.groovy b/gorm-tools/src/main/groovy/gorm/tools/job/SyncJobContext.groovy index 79b8bee7c..a5b1ee06e 100644 --- a/gorm-tools/src/main/groovy/gorm/tools/job/SyncJobContext.groovy +++ b/gorm-tools/src/main/groovy/gorm/tools/job/SyncJobContext.groovy @@ -95,7 +95,7 @@ class SyncJobContext { Map data = [ id: args.jobId, source: args.source, sourceId: args.sourceId, - state: SyncJobState.Running, payload: payload + state: args.jobState, payload: payload ] as Map if(payload instanceof Collection && payload.size() > 1000) { diff --git a/gorm-tools/src/main/groovy/gorm/tools/repository/GormRepo.groovy b/gorm-tools/src/main/groovy/gorm/tools/repository/GormRepo.groovy index 00894dae5..e64484fe4 100644 --- a/gorm-tools/src/main/groovy/gorm/tools/repository/GormRepo.groovy +++ b/gorm-tools/src/main/groovy/gorm/tools/repository/GormRepo.groovy @@ -497,7 +497,7 @@ trait GormRepo implements ApiCrudRepo, BulkableRepo, ResolvableTypeProv /** * load without hydrating - * + *x * @param id required, the id to get * @return the retrieved entity */ diff --git a/gorm-tools/src/main/groovy/gorm/tools/repository/bulk/BulkExportService.groovy b/gorm-tools/src/main/groovy/gorm/tools/repository/bulk/BulkExportService.groovy new file mode 100644 index 000000000..79889dfae --- /dev/null +++ b/gorm-tools/src/main/groovy/gorm/tools/repository/bulk/BulkExportService.groovy @@ -0,0 +1,166 @@ +/* +* Copyright 2025 Yak.Works - Licensed under the Apache License, Version 2.0 (the "License") +* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +*/ +package gorm.tools.repository.bulk + +import javax.inject.Inject + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j + +import gorm.tools.beans.Pager +import gorm.tools.job.SyncJobArgs +import gorm.tools.job.SyncJobContext +import gorm.tools.job.SyncJobEntity +import gorm.tools.job.SyncJobService +import gorm.tools.job.SyncJobState +import gorm.tools.mango.api.QueryArgs +import gorm.tools.metamap.services.MetaMapService +import gorm.tools.problem.ProblemHandler +import gorm.tools.repository.GormRepo +import grails.core.GrailsApplication +import yakworks.api.Result +import yakworks.api.problem.data.DataProblem +import yakworks.commons.lang.NameUtils +import yakworks.commons.lang.Validate +import yakworks.json.groovy.JsonEngine +import yakworks.meta.MetaMapList +import yakworks.spring.AppCtx + +@CompileStatic +@Slf4j +class BulkExportService { + + @Inject SyncJobService syncJobService + @Inject GrailsApplication grailsApplication + @Inject MetaMapService metaMapService + @Inject ProblemHandler problemHandler + + /** + * Creates a new bulk export job with status : "Queued" and QueryArgs stored in job payload + */ + Long scheduleBulkExportJob(SyncJobArgs syncJobArgs) { + if(syncJobArgs.queryArgs == null) throw DataProblem.of('error.query.qRequired').detail("q criteria required").toException() + //resulting data should be saved as a file + syncJobArgs.saveDataAsFile = true + syncJobArgs.jobState = SyncJobState.Queued + + //Store QueryArgs and includes list as payload, these are the two things we need when running export + //so that we can construct it back when syncjob runs + Map payload = [ + q: syncJobArgs.queryArgs.criteriaMap, + includes: syncJobArgs.includes, + domain: syncJobArgs.entityClass.simpleName + ] + SyncJobContext jobContext = syncJobService.createJob(syncJobArgs, payload) + return jobContext.jobId + } + + /** + * Loads queued bulkexport job, recreates JobArgs and context from payload and runs the job + * + * @param async, default=true, can be passed false for tests to keep tests clean + */ + Long runBulkExportJob(Long jobId, boolean async = true) { + //build job context and syncjobargs from previously saved syncjob's payload + SyncJobContext context = buildJobContext(jobId) + context.args.async = async + + //change job state to running + changeJobStatusToRunning(jobId) + + //load repo for the domain class name stored in payload + GormRepo repo = loadRepo(context.payload['domain'] as String) + context.args.entityClass = repo.entityClass + + //run job + return syncJobService.runJob(context.args.asyncArgs, context, () -> doBulkExport(context, repo)) + } + + /** + * Run bulk export job + */ + void doBulkExport(SyncJobContext jobContext, GormRepo repo) { + try { + //paginate and fetch data list, update job results for each page of data. + eachPage(repo, jobContext.args.queryArgs) { List pageData -> + //create metamap list with includes + MetaMapList entityMapList = metaMapService.createMetaMapList(pageData, jobContext.args.includes) + Result result = Result.OK().payload([data:entityMapList]) + //update job with page data + jobContext.updateJobResults(result, false) + } + } catch (Exception ex) { + log.error("BulkExport unexpected exception", ex) + jobContext.updateWithResult(problemHandler.handleUnexpected(ex)) + } + } + + + /** + * Recreates SyncjobArgs from previously slaved Syncjob. + * Builds QueryArgs and includes from job payload + */ + SyncJobArgs buildSyncJobArgs(Map payload) { + Validate.notEmpty(payload, "job payload is empty") + + Map q = payload['q'] + List includes = payload['includes'] + + SyncJobArgs syncJobArgs = SyncJobArgs.withParams(q:q) + syncJobArgs.includes = includes + + //bulk export always runs async and parallel + syncJobArgs.async = true + syncJobArgs.parallel = true + + //bulkexport always saves data in a file + syncJobArgs.saveDataAsFile = true + syncJobArgs.dataFormat = SyncJobArgs.DataFormat.Payload + return syncJobArgs + } + + /** + * Recreate jobcontext and jobargs from saved job + */ + SyncJobContext buildJobContext(Long jobId) { + SyncJobEntity job = syncJobService.getJob(jobId) + + String payloadStr = job.payloadToString() + Validate.notEmpty(payloadStr, "job payload is empty") + Map payload = JsonEngine.parseJson(payloadStr) as Map + + SyncJobContext context = SyncJobContext.of(buildSyncJobArgs(payload)).syncJobService(syncJobService).payload(payload) + context.args.jobId = jobId + return context + } + + /** + * Changes job state to Running before starting bulk export job + */ + void changeJobStatusToRunning(Serializable jobId) { + syncJobService.updateJob([id:jobId, state: SyncJobState.Running]) + } + + + GormRepo loadRepo(String domainName) { + return AppCtx.get("${NameUtils.getPropertyName(domainName)}Repo") as GormRepo + } + + /** + * Instead of loading all the data for bulkexport, it paginates and loads one page at a time + */ + void eachPage(GormRepo repo, QueryArgs queryArgs, Closure cl) { + //count total records based on query args and build a paginator + Integer totalRecords = repo.query(queryArgs).count() as Integer + Pager paginator = Pager.of(max:10) + paginator.recordCount = totalRecords + + paginator.eachPage { def max, def offset -> + List pageData = repo.query(queryArgs).pagedList(Pager.of(max:max, offset:offset)) + cl.call(pageData) + } + } + +} diff --git a/gorm-tools/src/main/groovy/gorm/tools/repository/bulk/BulkableRepo.groovy b/gorm-tools/src/main/groovy/gorm/tools/repository/bulk/BulkableRepo.groovy index a41ab1796..e973c3c7b 100644 --- a/gorm-tools/src/main/groovy/gorm/tools/repository/bulk/BulkableRepo.groovy +++ b/gorm-tools/src/main/groovy/gorm/tools/repository/bulk/BulkableRepo.groovy @@ -4,7 +4,6 @@ */ package gorm.tools.repository.bulk - import groovy.transform.CompileStatic import org.grails.datastore.mapping.core.Datastore @@ -263,4 +262,5 @@ trait BulkableRepo { def event = new AfterBulkSaveEntityEvent(self, entity, data, syncJobArgs) getRepoEventPublisher().publishEvents(self, event, [event] as Object[]) } + } diff --git a/gorm-tools/src/main/groovy/gorm/tools/repository/model/ApiCrudRepo.groovy b/gorm-tools/src/main/groovy/gorm/tools/repository/model/ApiCrudRepo.groovy index c218590be..624fbaf16 100644 --- a/gorm-tools/src/main/groovy/gorm/tools/repository/model/ApiCrudRepo.groovy +++ b/gorm-tools/src/main/groovy/gorm/tools/repository/model/ApiCrudRepo.groovy @@ -120,7 +120,6 @@ interface ApiCrudRepo { * @return Job id */ Long bulk(List dataList, SyncJobArgs syncJobArgs) - //--------------------Mango Query ------------------- /** diff --git a/gorm-tools/src/main/groovy/yakworks/gorm/api/CrudApi.groovy b/gorm-tools/src/main/groovy/yakworks/gorm/api/CrudApi.groovy index 60c4b6550..9797b0cb7 100644 --- a/gorm-tools/src/main/groovy/yakworks/gorm/api/CrudApi.groovy +++ b/gorm-tools/src/main/groovy/yakworks/gorm/api/CrudApi.groovy @@ -108,6 +108,8 @@ interface CrudApi { */ SyncJobEntity bulk(DataOp dataOp, List dataList, Map params, String sourceId) + SyncJobEntity bulkExport(Map params, String sourceId) + /** * Converts the instance to Map using the MetaMap wrapper with {@link gorm.tools.metamap.services.MetaMapService}. * diff --git a/gorm-tools/src/main/groovy/yakworks/gorm/api/DefaultCrudApi.groovy b/gorm-tools/src/main/groovy/yakworks/gorm/api/DefaultCrudApi.groovy index c7afe7719..577c63ebf 100644 --- a/gorm-tools/src/main/groovy/yakworks/gorm/api/DefaultCrudApi.groovy +++ b/gorm-tools/src/main/groovy/yakworks/gorm/api/DefaultCrudApi.groovy @@ -175,6 +175,7 @@ class DefaultCrudApi implements CrudApi { Pager list(Map qParams, URI uri){ Pager pager = Pager.of(qParams) QueryArgs qargs = createQueryArgs(pager, qParams, uri) + List dlist = queryList(qargs) List incs = getIncludes(qParams, [IncludesKey.list, IncludesKey.get]) return createPagerResult(pager, qParams, dlist, incs) @@ -197,6 +198,10 @@ class DefaultCrudApi implements CrudApi { return job } + SyncJobEntity bulkExport(Map params, String sourceId) { + return getBulkApiSupport().processBulkExport(params, sourceId) + } + protected List queryList(QueryArgs qargs) { return getApiCrudRepo().query(qargs, null).pagedList(qargs.pager) } diff --git a/gorm-tools/src/main/groovy/yakworks/gorm/api/support/BulkApiSupport.groovy b/gorm-tools/src/main/groovy/yakworks/gorm/api/support/BulkApiSupport.groovy index 48809297a..6909de1eb 100644 --- a/gorm-tools/src/main/groovy/yakworks/gorm/api/support/BulkApiSupport.groovy +++ b/gorm-tools/src/main/groovy/yakworks/gorm/api/support/BulkApiSupport.groovy @@ -4,7 +4,6 @@ */ package yakworks.gorm.api.support - import groovy.transform.CompileStatic import groovy.util.logging.Slf4j @@ -16,6 +15,7 @@ import gorm.tools.job.SyncJobService import gorm.tools.problem.ProblemHandler import gorm.tools.repository.GormRepo import gorm.tools.repository.RepoLookup +import gorm.tools.repository.bulk.BulkExportService import gorm.tools.repository.model.DataOp import yakworks.commons.lang.EnumUtils import yakworks.gorm.api.IncludesConfig @@ -40,6 +40,9 @@ class BulkApiSupport { @Autowired ProblemHandler problemHandler + @Autowired + BulkExportService bulkExportService + Class entityClass // the domain class this is for BulkApiSupport(Class entityClass){ @@ -81,6 +84,23 @@ class BulkApiSupport { return syncJobArgs } + SyncJobArgs setupBulkExportArgs(Map params, String sourceId){ + List bulkIncludes = includesConfig.findByKeys(getEntityClass(), [IncludesKey.list, IncludesKey.get]) + SyncJobArgs syncJobArgs = SyncJobArgs.withParams(params) + //syncJobArgs.op = DataOp.update.export + syncJobArgs.includes = bulkIncludes + syncJobArgs.sourceId = sourceId + syncJobArgs.entityClass = getEntityClass() + return syncJobArgs + } + + SyncJobEntity processBulkExport(Map params, String sourceId) { + SyncJobArgs args = setupBulkExportArgs(params, sourceId) + Long jobId = bulkExportService.scheduleBulkExportJob(args) + return syncJobService.getJob(jobId) + } + + GormRepo getRepo() { RepoLookup.findRepo(getEntityClass()) } diff --git a/gorm-tools/src/main/groovy/yakworks/gorm/boot/GormToolsConfiguration.groovy b/gorm-tools/src/main/groovy/yakworks/gorm/boot/GormToolsConfiguration.groovy index 05f47c580..c960cd58a 100644 --- a/gorm-tools/src/main/groovy/yakworks/gorm/boot/GormToolsConfiguration.groovy +++ b/gorm-tools/src/main/groovy/yakworks/gorm/boot/GormToolsConfiguration.groovy @@ -34,6 +34,7 @@ import gorm.tools.mango.QuickSearchSupport import gorm.tools.metamap.services.MetaEntityService import gorm.tools.metamap.services.MetaMapService import gorm.tools.problem.ProblemHandler +import gorm.tools.repository.bulk.BulkExportService import gorm.tools.repository.errors.RepoExceptionSupport import gorm.tools.repository.events.RepoEventPublisher import gorm.tools.transaction.TrxService @@ -149,4 +150,10 @@ class GormToolsConfiguration { new DefaultQueryArgsValidator() } + @Bean + @ConditionalOnMissingBean + BulkExportService bulkExportService() { + return new BulkExportService() + } + } From 71747e1a00a33bda4630669047af8cab271149e0 Mon Sep 17 00:00:00 2001 From: Joshua Burnett Date: Fri, 2 May 2025 09:42:40 -0600 Subject: [PATCH 5/7] diable secureCrudApi for now (#891) --- .../groovy/yakworks/rest/OrgRestApiSpec.groovy | 5 +++++ .../rest/gorm/controller/CrudApiController.groovy | 9 +++++++-- .../rest/gorm/mapping/RepoApiMappingsService.groovy | 1 + 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy index 7c45f2573..bacaedce3 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/OrgRestApiSpec.groovy @@ -6,6 +6,8 @@ import okhttp3.Request import okhttp3.RequestBody import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.springframework.http.HttpStatus + +import spock.lang.Ignore import spock.lang.IgnoreRest import yakworks.rest.client.OkAuth import yakworks.rest.client.OkHttpRestTrait @@ -416,6 +418,9 @@ class OrgRestApiSpec extends Specification implements OkHttpRestTrait, WithTrx { body.detail.contains "expecting '}'" } + //XXX @SUD turn back on when secureCrudApi is sorted out + // is this the only test we have? + @Ignore void "test readonly operation"() { setup: OkAuth.TOKEN = null diff --git a/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy b/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy index e5cf64985..764633d98 100644 --- a/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy +++ b/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy @@ -24,6 +24,7 @@ import gorm.tools.beans.Pager import gorm.tools.job.SyncJobEntity import gorm.tools.problem.ProblemHandler import gorm.tools.repository.model.DataOp +import gorm.tools.utils.ServiceLookup import grails.web.Action import yakworks.api.problem.Problem import yakworks.etl.csv.CsvToMapTransformer @@ -93,10 +94,14 @@ trait CrudApiController extends RestApiController { CrudApi getCrudApi(){ if (!crudApi) { + + //XXX future secureCrudApi, should be able to be turned on and off with config //not using ServiceLookup.lookup as we dont want to inject OrgCrudApi, even if its available, //Always inject SecureCrudApi, which wraps and delegates to configured custom CrudApi or DefaultCrudApi. - this.crudApi = (CrudApi)AppCtx.ctx.getBean("secureCrudApi", [getEntityClass()] as Object[]) - //this.crudApi = ServiceLookup.lookup(getEntityClass(), CrudApi, "secureCrudApi") + //this.crudApi = (CrudApi)AppCtx.ctx.getBean("secureCrudApi", [getEntityClass()] as Object[]) + + this.crudApi = ServiceLookup.lookup(getEntityClass(), CrudApi, "secureCrudApi") + //this.crudApi = crudApiFactory.apply(getEntityClass()) //this.crudApi = crudApiClosure.call(getEntityClass()) as CrudApi // try { diff --git a/gorm-rest/src/main/groovy/yakworks/rest/gorm/mapping/RepoApiMappingsService.groovy b/gorm-rest/src/main/groovy/yakworks/rest/gorm/mapping/RepoApiMappingsService.groovy index 25259c017..a969feeed 100644 --- a/gorm-rest/src/main/groovy/yakworks/rest/gorm/mapping/RepoApiMappingsService.groovy +++ b/gorm-rest/src/main/groovy/yakworks/rest/gorm/mapping/RepoApiMappingsService.groovy @@ -61,6 +61,7 @@ class RepoApiMappingsService { .httpMethod('PUT').action('bulkUpdate').suffix('/bulk') .urlMappingBuilder(builderDelegate).build() + //XXX @SUD, what this mean "not working, doesnt get picked up"? //bulk export FIXME @SUD, not working, doesnt get picked up SimpleUrlMappingBuilder.of(contextPath, nspace, ctrlName) .httpMethod('GET').action('bulkExport').suffix('/bulk') From e74cf163f009a9c69821677df5e9c93232d9a711 Mon Sep 17 00:00:00 2001 From: Joshua B Date: Thu, 8 May 2025 09:27:57 -0600 Subject: [PATCH 6/7] disable SecureCrudApi for now --- .../groovy/yakworks/rest/ReadonlyRestApiSpec.groovy | 3 +++ .../rally-api/src/main/resources/config/security.yml | 4 ++-- .../rest/gorm/controller/CrudApiController.groovy | 9 ++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/examples/rally-api/src/integration-test/groovy/yakworks/rest/ReadonlyRestApiSpec.groovy b/examples/rally-api/src/integration-test/groovy/yakworks/rest/ReadonlyRestApiSpec.groovy index 2c0184645..622fa7bab 100644 --- a/examples/rally-api/src/integration-test/groovy/yakworks/rest/ReadonlyRestApiSpec.groovy +++ b/examples/rally-api/src/integration-test/groovy/yakworks/rest/ReadonlyRestApiSpec.groovy @@ -3,10 +3,13 @@ package yakworks.rest import grails.testing.mixin.integration.Integration import okhttp3.Response import org.springframework.http.HttpStatus + +import spock.lang.Ignore import spock.lang.Specification import yakworks.rest.client.OkAuth import yakworks.rest.client.OkHttpRestTrait +@Ignore //XXX secureCrudApi, turn back on when we work it out. @Integration class ReadonlyRestApiSpec extends Specification implements OkHttpRestTrait { diff --git a/examples/rally-api/src/main/resources/config/security.yml b/examples/rally-api/src/main/resources/config/security.yml index b150723b3..f4c2d1f96 100644 --- a/examples/rally-api/src/main/resources/config/security.yml +++ b/examples/rally-api/src/main/resources/config/security.yml @@ -69,8 +69,8 @@ spring: client-id: b8d2... client-secret: 387e... okta: - client-id: 0o... - client-secret: JxSy... + client-id: 0oa71gzlnbWqlfmim5d7 + client-secret: JxSy-an9qGdxg-2kzYQFUh2FqsIMpS-amPsgAOM9 provider: okta: authorization-uri: https://dev-86091574.okta.com/oauth2/v1/authorize diff --git a/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy b/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy index 764633d98..af89b4b18 100644 --- a/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy +++ b/gorm-rest/src/main/groovy/yakworks/rest/gorm/controller/CrudApiController.groovy @@ -67,10 +67,9 @@ trait CrudApiController extends RestApiController { @Autowired CsvToMapTransformer csvToMapTransformer - // @Autowired //(required = false) - // ObjectProvider> crudApiProvider - @Autowired - private Function crudApiFactory + //Kept for reference, see comments in DefaultCrudApiConfiguration + // @Autowired + // private Function crudApiFactory // @Autowired @@ -100,7 +99,7 @@ trait CrudApiController extends RestApiController { //Always inject SecureCrudApi, which wraps and delegates to configured custom CrudApi or DefaultCrudApi. //this.crudApi = (CrudApi)AppCtx.ctx.getBean("secureCrudApi", [getEntityClass()] as Object[]) - this.crudApi = ServiceLookup.lookup(getEntityClass(), CrudApi, "secureCrudApi") + this.crudApi = ServiceLookup.lookup(getEntityClass(), CrudApi, "defaultCrudApi") //this.crudApi = crudApiFactory.apply(getEntityClass()) //this.crudApi = crudApiClosure.call(getEntityClass()) as CrudApi From 1f2bce5bc4dea2e63b6c393745ceb87da16e2b1c Mon Sep 17 00:00:00 2001 From: Joshua B Date: Mon, 12 May 2025 11:41:11 -0600 Subject: [PATCH 7/7] vGroovyCommons=3.17 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index a8911682e..7344d795a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -43,7 +43,7 @@ spock.version=2.3-groovy-3.0 vSpringGrailsKit=5.4 vYakICU4J=5.5 -vGroovyCommons=3.17-SNAPSHOT +vGroovyCommons=3.17 # logging vLog4j=2.17.1